diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..b149b0a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 4
+
+[docker-compose.yml]
+indent_size = 4
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..a10dcf2
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1,10 @@
+# Community Guidelines
+
+The following community guidelines are based on [The Ruby Community Conduct Guidelines](https://www.ruby-lang.org/en/conduct).
+
+This document provides community guidelines for a respectful, productive, and collaborative place for any person who is willing to contribute to the project. It applies to all “collaborative space”, which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.).
+
+- Participants will be tolerant of opposing views.
+- Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
+- When interpreting the words and actions of others, participants should always assume good intentions.
+- Behaviour which can be reasonably considered harassment will not be tolerated.
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..d95ba1b
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,66 @@
+# Contributing Guidelines
+
+✨ Before we get started, thank you for taking the time to contribute! ✨
+
+This is a guideline for contributing to the project, its documentation, and other repositories. We welcome your feedback, proposed changes, and updates to these guidelines. We will always welcome thoughtful issues and consider pull requests.
+
+But, please take a moment to review this document **before submitting a pull request**.
+
+## Before contributing
+
+### Transl isn’t FOSS
+
+While Transl's source code is open source, publicly available, and welcomes contributions, it is proprietary. Everything in this repository, including any community-contributed code, is the property of Transl. For that reason there are a few limitations on how you can use the code:
+
+- You cannot alter anything related to licensing, updating, version or edition checking, purchasing, first party notifications or banners, or anything else that attempts to circumvent paying for features that are designated as paid features. We want to stay in business so we can better support _you_ and the community.
+- You can’t publicly maintain a long-term fork of the repository.
+
+### How to Get Support
+
+If you're looking for official developer support (and you have an active license/subscription), send us an email at the address that can be found on [Transl.me](https://transl.me). We will always do our best to reply in a timely manner. **Github issues are intended for reporting bugs.**
+
+## Contributing
+
+### Pull requests
+
+**Please ask first before starting work on any significant new features.**
+
+It's never a fun experience to have your pull request declined after investing a lot of time and effort into a new feature. To avoid this from happening, we request that contributors [share their idea with us](https://github.com/transl-me/laravel-transl/discussions/new?category=ideas) in our discussion forum to first discuss any significant new features.
+
+### Coding standards
+
+Our code formatting rules are defined in [pint.json](https://github.com/transl-me/laravel-transl/blob/main/pint.json). You can check your code against these standards by running:
+
+```sh
+composer format
+```
+
+To automatically fix any style violations in your code, you can run:
+
+```sh
+composer fix
+```
+
+### Static analysis
+
+You can analyse the codebase with phpstan using the following command:
+
+```sh
+composer analyse
+```
+
+or the alias:
+
+```sh
+composer lint
+```
+
+### Running tests
+
+You can run the test suite using the following commands:
+
+```sh
+composer test
+```
+
+Please ensure that the tests are passing when submitting a pull request. If you're adding new features, please include tests.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..2048c4d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,14 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Ask a question
+ url: https://github.com/transl-me/laravel-transl/discussions/new?category=q-a
+ about: Ask the community for help
+ - name: Request a feature
+ url: https://github.com/transl-me/laravel-transl/discussions/new?category=ideas
+ about: Share ideas for new features
+ - name: Report a security issue
+ url: https://github.com/transl-me/laravel-transl/security/policy
+ about: Learn how to notify us for sensitive bugs
+ - name: Report a bug
+ url: https://github.com/transl-me/laravel-transl/issues/new
+ about: Report a reproducable bug
diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml
new file mode 100644
index 0000000..8efd3c5
--- /dev/null
+++ b/.github/workflows/fix-php-code-style-issues.yml
@@ -0,0 +1,28 @@
+name: Fix PHP code style issues
+
+on:
+ push:
+ paths:
+ - '**.php'
+
+permissions:
+ contents: write
+
+jobs:
+ php-code-styling:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Fix PHP code style issues
+ uses: aglipanci/laravel-pint-action@2.4
+
+ - name: Commit changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: 'chore: formatting'
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644
index 0000000..2183351
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,28 @@
+name: PHPStan
+
+on:
+ push:
+ paths:
+ - '**.php'
+ - 'phpstan.neon.dist'
+ - '.github/workflows/phpstan.yml'
+
+jobs:
+ phpstan:
+ name: phpstan
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: none
+
+ - name: Install composer dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Run PHPStan
+ run: ./vendor/bin/phpstan --error-format=github
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 0000000..fafc165
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,59 @@
+name: run-tests
+
+on:
+ push:
+ paths:
+ - '**.php'
+ - '.github/workflows/run-tests.yml'
+ - 'phpunit.xml.dist'
+ - 'composer.json'
+ - 'composer.lock'
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 5
+ strategy:
+ fail-fast: true
+ matrix:
+ os: [ubuntu-latest] # windows-latest, macos-latest
+ php: [8.4, 8.3, 8.2, 8.1]
+ laravel: [11.*, 10.*]
+ stability: [prefer-lowest, prefer-stable]
+ include:
+ - laravel: 11.*
+ testbench: 9.*
+ - laravel: 10.*
+ testbench: 8.*
+ exclude:
+ - php: 8.1
+ laravel: 11.*
+
+ name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
+ coverage: pcov
+
+ - name: Setup problem matchers
+ run: |
+ echo "::add-matcher::${{ runner.tool_cache }}/php.json"
+ echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+
+ - name: Install dependencies
+ run: |
+ composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
+ composer update --${{ matrix.stability }} --prefer-dist --no-interaction
+
+ - name: List Installed Dependencies
+ run: composer show -D
+
+ - name: Execute tests
+ run: vendor/bin/pest --ci --coverage --min=90
diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml
new file mode 100644
index 0000000..d16421d
--- /dev/null
+++ b/.github/workflows/update-changelog.yml
@@ -0,0 +1,32 @@
+name: 'Update Changelog'
+
+on:
+ release:
+ types: [released]
+
+permissions:
+ contents: write
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Update Changelog
+ uses: stefanzweifel/changelog-updater-action@v1
+ with:
+ latest-version: ${{ github.event.release.name }}
+ release-notes: ${{ github.event.release.body }}
+
+ - name: Commit updated CHANGELOG
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ branch: main
+ commit_message: 'chore: auto changelog update'
+ file_pattern: CHANGELOG.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..feff72e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+*.log
+*.local
+*.vic
+.env
+.env.production
+.env.staging
+.env.backup
+.phpunit.result.cache
+.npmrc
+auth.json
+/.vic
+/.idea
+/.vscode
+/.fleet
+/.results
+/.coverage
+/.temp
+/node_modules
+# /public/build
+/public/hot
+/public/storage
+/storage/*.key
+/tests/TestSupport/.to-delete
+/vendor
+/resources/tests/vitest/.coverage
+/resources/tests/playwright/.test-results
+/playwright-report/
+/playwright/.cache/
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100644
index 0000000..5ec6e36
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+# npx --no-install commitlint --edit $1
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100644
index 0000000..355f1f6
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1,6 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+# composer format
+# composer lint
+# composer test:coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..30e5fce
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+All notable changes to `transl-me/laravel-transl` will be documented in this file.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..905b074
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,46 @@
+Copyright (c) Transl
+
+Permission is hereby granted, provided an exchange beneficial to the business
+of Transl, or, provided there is express intent to result in an exchange
+beneficial to the business of Transl, to any person obtaining a copy of this
+software (the "Software") to use, copy, modify, merge, publish and/or distribute
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+1. **Do not plagiarize.** The Software and the proprietary code therein, not
+ limited to but including designs, components, assets, classes, and patterns,
+ are attributed to and solely to Transl and it's founders.
+
+2. **Not for reuse.** The Software and the proprietary code therein, not limited
+ to but including designs, components, assets, classes, and patterns, may not
+ be reused in other projects without the express written consent of Transl.
+
+3. **Do not alter the licensing features.** Software features related to licensing
+ shall not be altered or circumvented in any way, including (but not limited to)
+ license validation, feature or edition restrictions, and update eligibility.
+
+4. **Do not compete.** All use of the Software shall not violate anything that may
+ be deemed by Transl, in their sole and absolute discretion, to be competitive or
+ in conflict with the business of Transl.
+
+5. **Follow the law.** All use of the Software shall not violate any applicable law
+ or regulation, nor infringe the rights of any other person or entity.
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+Failure to comply with the foregoing conditions will automatically and immediately
+result in termination of the permission granted hereby. This license does
+not include any right to receive updates to the Software or technical support.
+Licensees bear all risk related to the quality and performance of the Software
+and any modifications made or obtained to it, including liability for actual and
+consequential harm, such as loss or corruption of data, and any necessary service,
+repair, or correction.
+
+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, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, 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.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9cd2fc7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,124 @@
+# Package for the Laravel translation manager and database : Transl.me
+
+[![GitHub Tests Action Status](https://github.com/transl-me/laravel-transl/actions/workflows/run-tests.yml/badge.svg)](https://github.com/transl-me/laravel-transl/actions/workflows/run-tests.yml)
+[![GitHub PHPStan Action Status](https://github.com/transl-me/laravel-transl/actions/workflows/phpstan.yml/badge.svg)](https://github.com/transl-me/laravel-transl/actions/workflows/phpstan.yml)
+[![GitHub Code Style Action Status](https://github.com/transl-me/laravel-transl/actions/workflows/fix-php-code-style-issues.yml/badge.svg)](https://github.com/transl-me/laravel-transl/actions/workflows/fix-php-code-style-issues.yml)
+[![Latest Version on Packagist](https://img.shields.io/packagist/v/transl-me/laravel-transl.svg?style=flat-square)](https://packagist.org/packages/transl-me/laravel-transl)
+[![Total Downloads](https://img.shields.io/packagist/dt/transl-me/laravel-transl.svg?style=flat-square)](https://packagist.org/packages/transl-me/laravel-transl)
+
+---
+
+This package allows for pushing and pulling your Laravel localized content _(translation files by default)_ to [Transl.me](https://transl.me).
+
+> [!TIP]
+> Transl is a platform for developers, product owners, managers and translators to easily manage and automate localized content in a Laravel application. Localisation shouldn't be a burden on developers, it should be a burden on us.
+
+## Installation
+
+You can install the package via composer:
+
+```bash
+composer require transl-me/laravel-transl
+```
+
+You can publish the config file with:
+
+```bash
+php artisan vendor:publish --tag="transl-config"
+```
+
+You can check out what the contents of the published config file will be here: [config/transl.php](/config/transl.php).
+
+## Usage _(Available commands)_
+
+### Init
+
+A one time command that initializes the defined project _(pushes the initial translation lines)_ on Transl.me.
+
+```bash
+php artisan transl:init
+```
+
+Check out [the command's signature](/src/Commands/TranslInitCommand.php) to learn more about it's possible options.
+
+### Push
+
+Pushes the defined project's translation lines to Transl.me.
+
+```bash
+php artisan transl:push
+```
+
+Check out [the command's signature](/src/Commands/TranslPushCommand.php) to learn more about it's possible options.
+
+### Pull
+
+Retrieves and stores the defined project's translation lines from Transl.me.
+
+```bash
+php artisan transl:pull
+```
+
+> [!NOTE]
+> Unfortunately, when using with local translation files, we cannot guarantee the preservation of the original language file's formatting.
+> This is because the language file contents are sent and retreive as JSON to and from Transl through HTTP.
+> Therefore, any dynamic content and variables inside your translation files will be evualuated before being sent to Transl.
+> No formatting information is transfered. Upon retrieval, the file's contents are reconstructed without the previously lost formating informations.
+
+> [!TIP]
+> Ensure any previous local changes are versioned.
+
+Check out [the command's signature](/src/Commands/TranslPullCommand.php) to learn more about it's possible options.
+
+### Synch
+
+Pulls then pushes the defined project's translation lines to Transl.me.
+
+```bash
+php artisan transl:synch
+```
+
+> [!NOTE]
+> Same as for the push command regarding the inability to reconstruct the translation file's original content formatting _(when using with local translation files)_.
+
+> [!TIP]
+> Ensure any previous local changes are versioned.
+
+Check out [the command's signature](/src/Commands/TranslSynchCommand.php) to learn more about it's possible options.
+
+### Analyse
+
+Analyses the defined project's translation lines.
+
+```bash
+php artisan transl:analyse
+```
+
+Check out [the command's signature](/src/Commands/TranslAnalyseCommand.php) to learn more about it's possible options.
+
+## Testing
+
+```bash
+composer test
+```
+
+## Changelog
+
+Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
+
+## Contributing
+
+If you're interested in contributing to the project, please read our [contributing docs](https://github.com/transl-me/laravel-transl/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**.
+
+## Security Vulnerabilities
+
+Please review [our security policy](../../security/policy) on how to report security vulnerabilities.
+
+## Credits
+
+- [Victor GUTT](https://github.com/vicgutt)
+- [All Contributors](../../contributors)
+
+## License
+
+Please see [License File](LICENSE) for more information.
diff --git a/commitlint.config.cjs b/commitlint.config.cjs
new file mode 100644
index 0000000..422b194
--- /dev/null
+++ b/commitlint.config.cjs
@@ -0,0 +1 @@
+module.exports = { extends: ['@commitlint/config-conventional'] };
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..189ff9f
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,80 @@
+{
+ "name": "transl-me/laravel-transl",
+ "description": "Package for the Laravel translation management service : Transl.me",
+ "type": "library",
+ "license": "proprietary",
+ "keywords": [
+ "laravel",
+ "php",
+ "localization",
+ "translation",
+ "translation manager"
+ ],
+ "homepage": "https://github.com/transl-me/laravel-transl",
+ "authors": [
+ {
+ "name": "Victor GUTT",
+ "email": "guttvictor@yahoo.fr",
+ "homepage": "https://victorgutt.dev",
+ "role": "Developer"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Transl\\": "src"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Transl\\Tests\\": "tests"
+ }
+ },
+ "scripts": {
+ "analyse": "vendor/bin/phpstan analyse --memory-limit=1G",
+ "lint": "composer analyse",
+ "test": "vendor/bin/pest",
+ "test:coverage": "vendor/bin/pest --coverage --min=90",
+ "test:ordered": "vendor/bin/pest --order-by=default",
+ "test:[filtered]": "vendor/bin/pest --filter=ExampleTest",
+ "format": "vendor/bin/pint --test",
+ "format:fix": "vendor/bin/pint",
+ "fix": "composer format:fix"
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "pestphp/pest-plugin": true,
+ "phpstan/extension-installer": true
+ }
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Transl\\TranslServiceProvider"
+ ],
+ "aliases": {
+ "Transl": "Transl\\Facades\\Transl"
+ }
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "require": {
+ "php": "^8.1",
+ "illuminate/contracts": "^10.0||^11.0",
+ "spatie/laravel-package-tools": "^1.16"
+ },
+ "require-dev": {
+ "guzzlehttp/guzzle": "^7.8",
+ "larastan/larastan": "^2.7",
+ "laravel/pint": "^1.13",
+ "nunomaduro/collision": "^8.1.1||^7.10.0",
+ "orchestra/testbench": "^9.0.0||^8.22.0",
+ "pestphp/pest": "^2.30",
+ "pestphp/pest-plugin-laravel": "^2.2",
+ "phpstan/extension-installer": "^1.3",
+ "phpstan/phpstan-deprecation-rules": "^1.1",
+ "phpstan/phpstan-phpunit": "^1.3",
+ "phpunit/phpunit": "^10.5"
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..1beb470
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,10272 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "b55aeb6bb1d6eec73a30f4e9713e8560",
+ "packages": [
+ {
+ "name": "brick/math",
+ "version": "0.12.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/brick/math.git",
+ "reference": "f510c0a40911935b77b86859eb5223d58d660df1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1",
+ "reference": "f510c0a40911935b77b86859eb5223d58d660df1",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.2",
+ "phpunit/phpunit": "^10.1",
+ "vimeo/psalm": "5.16.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Brick\\Math\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Arbitrary-precision arithmetic library",
+ "keywords": [
+ "Arbitrary-precision",
+ "BigInteger",
+ "BigRational",
+ "arithmetic",
+ "bigdecimal",
+ "bignum",
+ "bignumber",
+ "brick",
+ "decimal",
+ "integer",
+ "math",
+ "mathematics",
+ "rational"
+ ],
+ "support": {
+ "issues": "https://github.com/brick/math/issues",
+ "source": "https://github.com/brick/math/tree/0.12.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/BenMorel",
+ "type": "github"
+ }
+ ],
+ "time": "2023-11-29T23:19:16+00:00"
+ },
+ {
+ "name": "carbonphp/carbon-doctrine-types",
+ "version": "3.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git",
+ "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
+ "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "conflict": {
+ "doctrine/dbal": "<4.0.0 || >=5.0.0"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^4.0.0",
+ "nesbot/carbon": "^2.71.0 || ^3.0.0",
+ "phpunit/phpunit": "^10.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "KyleKatarn",
+ "email": "kylekatarnls@gmail.com"
+ }
+ ],
+ "description": "Types to use Carbon in Doctrine",
+ "keywords": [
+ "carbon",
+ "date",
+ "datetime",
+ "doctrine",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues",
+ "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/kylekatarnls",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/Carbon",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-02-09T16:56:22+00:00"
+ },
+ {
+ "name": "dflydev/dot-access-data",
+ "version": "v3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dflydev/dflydev-dot-access-data.git",
+ "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
+ "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^0.12.42",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
+ "scrutinizer/ocular": "1.6.0",
+ "squizlabs/php_codesniffer": "^3.5",
+ "vimeo/psalm": "^4.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Dflydev\\DotAccessData\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Dragonfly Development Inc.",
+ "email": "info@dflydev.com",
+ "homepage": "http://dflydev.com"
+ },
+ {
+ "name": "Beau Simensen",
+ "email": "beau@dflydev.com",
+ "homepage": "http://beausimensen.com"
+ },
+ {
+ "name": "Carlos Frutos",
+ "email": "carlos@kiwing.it",
+ "homepage": "https://github.com/cfrutos"
+ },
+ {
+ "name": "Colin O'Dell",
+ "email": "colinodell@gmail.com",
+ "homepage": "https://www.colinodell.com"
+ }
+ ],
+ "description": "Given a deep data structure, access data by dot notation.",
+ "homepage": "https://github.com/dflydev/dflydev-dot-access-data",
+ "keywords": [
+ "access",
+ "data",
+ "dot",
+ "notation"
+ ],
+ "support": {
+ "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
+ "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
+ },
+ "time": "2024-07-08T12:26:09+00:00"
+ },
+ {
+ "name": "doctrine/inflector",
+ "version": "2.0.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/inflector.git",
+ "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc",
+ "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^11.0",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpstan/phpstan-strict-rules": "^1.3",
+ "phpunit/phpunit": "^8.5 || ^9.5",
+ "vimeo/psalm": "^4.25 || ^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Inflector\\": "lib/Doctrine/Inflector"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Guilherme Blanco",
+ "email": "guilhermeblanco@gmail.com"
+ },
+ {
+ "name": "Roman Borschel",
+ "email": "roman@code-factory.org"
+ },
+ {
+ "name": "Benjamin Eberlei",
+ "email": "kontakt@beberlei.de"
+ },
+ {
+ "name": "Jonathan Wage",
+ "email": "jonwage@gmail.com"
+ },
+ {
+ "name": "Johannes Schmitt",
+ "email": "schmittjoh@gmail.com"
+ }
+ ],
+ "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.",
+ "homepage": "https://www.doctrine-project.org/projects/inflector.html",
+ "keywords": [
+ "inflection",
+ "inflector",
+ "lowercase",
+ "manipulation",
+ "php",
+ "plural",
+ "singular",
+ "strings",
+ "uppercase",
+ "words"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/inflector/issues",
+ "source": "https://github.com/doctrine/inflector/tree/2.0.10"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-02-18T20:23:39+00:00"
+ },
+ {
+ "name": "doctrine/lexer",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/lexer.git",
+ "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
+ "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^12",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^10.5",
+ "psalm/plugin-phpunit": "^0.18.3",
+ "vimeo/psalm": "^5.21"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Common\\Lexer\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Guilherme Blanco",
+ "email": "guilhermeblanco@gmail.com"
+ },
+ {
+ "name": "Roman Borschel",
+ "email": "roman@code-factory.org"
+ },
+ {
+ "name": "Johannes Schmitt",
+ "email": "schmittjoh@gmail.com"
+ }
+ ],
+ "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.",
+ "homepage": "https://www.doctrine-project.org/projects/lexer.html",
+ "keywords": [
+ "annotations",
+ "docblock",
+ "lexer",
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/lexer/issues",
+ "source": "https://github.com/doctrine/lexer/tree/3.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-02-05T11:56:58+00:00"
+ },
+ {
+ "name": "dragonmantank/cron-expression",
+ "version": "v3.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dragonmantank/cron-expression.git",
+ "reference": "8c784d071debd117328803d86b2097615b457500"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
+ "reference": "8c784d071debd117328803d86b2097615b457500",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2|^8.0",
+ "webmozart/assert": "^1.0"
+ },
+ "replace": {
+ "mtdowling/cron-expression": "^1.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^1.0",
+ "phpunit/phpunit": "^7.0|^8.0|^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Cron\\": "src/Cron/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Chris Tankersley",
+ "email": "chris@ctankersley.com",
+ "homepage": "https://github.com/dragonmantank"
+ }
+ ],
+ "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due",
+ "keywords": [
+ "cron",
+ "schedule"
+ ],
+ "support": {
+ "issues": "https://github.com/dragonmantank/cron-expression/issues",
+ "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/dragonmantank",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-09T13:47:03+00:00"
+ },
+ {
+ "name": "egulias/email-validator",
+ "version": "4.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/egulias/EmailValidator.git",
+ "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ebaaf5be6c0286928352e054f2d5125608e5405e",
+ "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/lexer": "^2.0 || ^3.0",
+ "php": ">=8.1",
+ "symfony/polyfill-intl-idn": "^1.26"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.2",
+ "vimeo/psalm": "^5.12"
+ },
+ "suggest": {
+ "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Egulias\\EmailValidator\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Eduardo Gulias Davis"
+ }
+ ],
+ "description": "A library for validating emails against several RFCs",
+ "homepage": "https://github.com/egulias/EmailValidator",
+ "keywords": [
+ "email",
+ "emailvalidation",
+ "emailvalidator",
+ "validation",
+ "validator"
+ ],
+ "support": {
+ "issues": "https://github.com/egulias/EmailValidator/issues",
+ "source": "https://github.com/egulias/EmailValidator/tree/4.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/egulias",
+ "type": "github"
+ }
+ ],
+ "time": "2023-10-06T06:47:41+00:00"
+ },
+ {
+ "name": "fruitcake/php-cors",
+ "version": "v1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/fruitcake/php-cors.git",
+ "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b",
+ "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0",
+ "symfony/http-foundation": "^4.4|^5.4|^6|^7"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.4",
+ "phpunit/phpunit": "^9",
+ "squizlabs/php_codesniffer": "^3.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.2-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Fruitcake\\Cors\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fruitcake",
+ "homepage": "https://fruitcake.nl"
+ },
+ {
+ "name": "Barryvdh",
+ "email": "barryvdh@gmail.com"
+ }
+ ],
+ "description": "Cross-origin resource sharing library for the Symfony HttpFoundation",
+ "homepage": "https://github.com/fruitcake/php-cors",
+ "keywords": [
+ "cors",
+ "laravel",
+ "symfony"
+ ],
+ "support": {
+ "issues": "https://github.com/fruitcake/php-cors/issues",
+ "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://fruitcake.nl",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/barryvdh",
+ "type": "github"
+ }
+ ],
+ "time": "2023-10-12T05:21:21+00:00"
+ },
+ {
+ "name": "graham-campbell/result-type",
+ "version": "v1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/GrahamCampbell/Result-Type.git",
+ "reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
+ "reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "phpoption/phpoption": "^1.9.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "GrahamCampbell\\ResultType\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ }
+ ],
+ "description": "An Implementation Of The Result Type",
+ "keywords": [
+ "Graham Campbell",
+ "GrahamCampbell",
+ "Result Type",
+ "Result-Type",
+ "result"
+ ],
+ "support": {
+ "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
+ "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-07-20T21:45:45+00:00"
+ },
+ {
+ "name": "guzzlehttp/guzzle",
+ "version": "7.9.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/guzzle.git",
+ "reference": "d281ed313b989f213357e3be1a179f02196ac99b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b",
+ "reference": "d281ed313b989f213357e3be1a179f02196ac99b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/promises": "^1.5.3 || ^2.0.3",
+ "guzzlehttp/psr7": "^2.7.0",
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-client": "^1.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "provide": {
+ "psr/http-client-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "ext-curl": "*",
+ "guzzle/client-integration-tests": "3.0.2",
+ "php-http/message-factory": "^1.1",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "suggest": {
+ "ext-curl": "Required for CURL handler support",
+ "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+ "psr/log": "Required for using the Log middleware"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Jeremy Lindblom",
+ "email": "jeremeamia@gmail.com",
+ "homepage": "https://github.com/jeremeamia"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle is a PHP HTTP client library",
+ "keywords": [
+ "client",
+ "curl",
+ "framework",
+ "http",
+ "http client",
+ "psr-18",
+ "psr-7",
+ "rest",
+ "web service"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/7.9.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-07-24T11:22:20+00:00"
+ },
+ {
+ "name": "guzzlehttp/promises",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/promises.git",
+ "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
+ "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Promise\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle promises library",
+ "keywords": [
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-10-17T10:06:22+00:00"
+ },
+ {
+ "name": "guzzlehttp/psr7",
+ "version": "2.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/psr7.git",
+ "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
+ "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0",
+ "ralouphie/getallheaders": "^3.0"
+ },
+ "provide": {
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "http-interop/http-factory-tests": "0.9.0",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+ },
+ "suggest": {
+ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
+ ],
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-07-18T11:15:46+00:00"
+ },
+ {
+ "name": "guzzlehttp/uri-template",
+ "version": "v1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/uri-template.git",
+ "reference": "ecea8feef63bd4fef1f037ecb288386999ecc11c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/uri-template/zipball/ecea8feef63bd4fef1f037ecb288386999ecc11c",
+ "reference": "ecea8feef63bd4fef1f037ecb288386999ecc11c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "symfony/polyfill-php80": "^1.24"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "phpunit/phpunit": "^8.5.36 || ^9.6.15",
+ "uri-template/tests": "1.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\UriTemplate\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ }
+ ],
+ "description": "A polyfill class for uri_template of PHP",
+ "keywords": [
+ "guzzlehttp",
+ "uri-template"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/uri-template/issues",
+ "source": "https://github.com/guzzle/uri-template/tree/v1.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-12-03T19:50:20+00:00"
+ },
+ {
+ "name": "laravel/framework",
+ "version": "v11.29.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/framework.git",
+ "reference": "425054512c362835ba9c0307561973c8eeac7385"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/425054512c362835ba9c0307561973c8eeac7385",
+ "reference": "425054512c362835ba9c0307561973c8eeac7385",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12",
+ "composer-runtime-api": "^2.2",
+ "doctrine/inflector": "^2.0.5",
+ "dragonmantank/cron-expression": "^3.3.2",
+ "egulias/email-validator": "^3.2.1|^4.0",
+ "ext-ctype": "*",
+ "ext-filter": "*",
+ "ext-hash": "*",
+ "ext-mbstring": "*",
+ "ext-openssl": "*",
+ "ext-session": "*",
+ "ext-tokenizer": "*",
+ "fruitcake/php-cors": "^1.3",
+ "guzzlehttp/guzzle": "^7.8",
+ "guzzlehttp/uri-template": "^1.0",
+ "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0",
+ "laravel/serializable-closure": "^1.3",
+ "league/commonmark": "^2.2.1",
+ "league/flysystem": "^3.8.0",
+ "monolog/monolog": "^3.0",
+ "nesbot/carbon": "^2.72.2|^3.0",
+ "nunomaduro/termwind": "^2.0",
+ "php": "^8.2",
+ "psr/container": "^1.1.1|^2.0.1",
+ "psr/log": "^1.0|^2.0|^3.0",
+ "psr/simple-cache": "^1.0|^2.0|^3.0",
+ "ramsey/uuid": "^4.7",
+ "symfony/console": "^7.0",
+ "symfony/error-handler": "^7.0",
+ "symfony/finder": "^7.0",
+ "symfony/http-foundation": "^7.0",
+ "symfony/http-kernel": "^7.0",
+ "symfony/mailer": "^7.0",
+ "symfony/mime": "^7.0",
+ "symfony/polyfill-php83": "^1.28",
+ "symfony/process": "^7.0",
+ "symfony/routing": "^7.0",
+ "symfony/uid": "^7.0",
+ "symfony/var-dumper": "^7.0",
+ "tijsverkoyen/css-to-inline-styles": "^2.2.5",
+ "vlucas/phpdotenv": "^5.4.1",
+ "voku/portable-ascii": "^2.0"
+ },
+ "conflict": {
+ "mockery/mockery": "1.6.8",
+ "tightenco/collect": "<5.5.33"
+ },
+ "provide": {
+ "psr/container-implementation": "1.1|2.0",
+ "psr/log-implementation": "1.0|2.0|3.0",
+ "psr/simple-cache-implementation": "1.0|2.0|3.0"
+ },
+ "replace": {
+ "illuminate/auth": "self.version",
+ "illuminate/broadcasting": "self.version",
+ "illuminate/bus": "self.version",
+ "illuminate/cache": "self.version",
+ "illuminate/collections": "self.version",
+ "illuminate/concurrency": "self.version",
+ "illuminate/conditionable": "self.version",
+ "illuminate/config": "self.version",
+ "illuminate/console": "self.version",
+ "illuminate/container": "self.version",
+ "illuminate/contracts": "self.version",
+ "illuminate/cookie": "self.version",
+ "illuminate/database": "self.version",
+ "illuminate/encryption": "self.version",
+ "illuminate/events": "self.version",
+ "illuminate/filesystem": "self.version",
+ "illuminate/hashing": "self.version",
+ "illuminate/http": "self.version",
+ "illuminate/log": "self.version",
+ "illuminate/macroable": "self.version",
+ "illuminate/mail": "self.version",
+ "illuminate/notifications": "self.version",
+ "illuminate/pagination": "self.version",
+ "illuminate/pipeline": "self.version",
+ "illuminate/process": "self.version",
+ "illuminate/queue": "self.version",
+ "illuminate/redis": "self.version",
+ "illuminate/routing": "self.version",
+ "illuminate/session": "self.version",
+ "illuminate/support": "self.version",
+ "illuminate/testing": "self.version",
+ "illuminate/translation": "self.version",
+ "illuminate/validation": "self.version",
+ "illuminate/view": "self.version",
+ "spatie/once": "*"
+ },
+ "require-dev": {
+ "ably/ably-php": "^1.0",
+ "aws/aws-sdk-php": "^3.235.5",
+ "ext-gmp": "*",
+ "fakerphp/faker": "^1.23",
+ "league/flysystem-aws-s3-v3": "^3.0",
+ "league/flysystem-ftp": "^3.0",
+ "league/flysystem-path-prefixing": "^3.3",
+ "league/flysystem-read-only": "^3.3",
+ "league/flysystem-sftp-v3": "^3.0",
+ "mockery/mockery": "^1.6",
+ "nyholm/psr7": "^1.2",
+ "orchestra/testbench-core": "^9.5",
+ "pda/pheanstalk": "^5.0",
+ "phpstan/phpstan": "^1.11.5",
+ "phpunit/phpunit": "^10.5|^11.0",
+ "predis/predis": "^2.0.2",
+ "resend/resend-php": "^0.10.0",
+ "symfony/cache": "^7.0",
+ "symfony/http-client": "^7.0",
+ "symfony/psr-http-message-bridge": "^7.0"
+ },
+ "suggest": {
+ "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
+ "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.235.5).",
+ "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).",
+ "ext-apcu": "Required to use the APC cache driver.",
+ "ext-fileinfo": "Required to use the Filesystem class.",
+ "ext-ftp": "Required to use the Flysystem FTP driver.",
+ "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().",
+ "ext-memcached": "Required to use the memcache cache driver.",
+ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.",
+ "ext-pdo": "Required to use all database features.",
+ "ext-posix": "Required to use all features of the queue worker.",
+ "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).",
+ "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).",
+ "filp/whoops": "Required for friendly error pages in development (^2.14.3).",
+ "laravel/tinker": "Required to use the tinker console command (^2.0).",
+ "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).",
+ "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).",
+ "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.3).",
+ "league/flysystem-read-only": "Required to use read-only disks (^3.3)",
+ "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).",
+ "mockery/mockery": "Required to use mocking (^1.6).",
+ "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).",
+ "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).",
+ "phpunit/phpunit": "Required to use assertions and run tests (^10.5|^11.0).",
+ "predis/predis": "Required to use the predis connector (^2.0.2).",
+ "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).",
+ "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).",
+ "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).",
+ "symfony/cache": "Required to PSR-6 cache bridge (^7.0).",
+ "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).",
+ "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).",
+ "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).",
+ "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).",
+ "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "11.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Illuminate/Collections/helpers.php",
+ "src/Illuminate/Events/functions.php",
+ "src/Illuminate/Filesystem/functions.php",
+ "src/Illuminate/Foundation/helpers.php",
+ "src/Illuminate/Log/functions.php",
+ "src/Illuminate/Support/functions.php",
+ "src/Illuminate/Support/helpers.php"
+ ],
+ "psr-4": {
+ "Illuminate\\": "src/Illuminate/",
+ "Illuminate\\Support\\": [
+ "src/Illuminate/Macroable/",
+ "src/Illuminate/Collections/",
+ "src/Illuminate/Conditionable/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "The Laravel Framework.",
+ "homepage": "https://laravel.com",
+ "keywords": [
+ "framework",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/framework/issues",
+ "source": "https://github.com/laravel/framework"
+ },
+ "time": "2024-10-22T14:13:31+00:00"
+ },
+ {
+ "name": "laravel/prompts",
+ "version": "v0.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/prompts.git",
+ "reference": "0f3848a445562dac376b27968f753c65e7e1036e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/prompts/zipball/0f3848a445562dac376b27968f753c65e7e1036e",
+ "reference": "0f3848a445562dac376b27968f753c65e7e1036e",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.2",
+ "ext-mbstring": "*",
+ "php": "^8.1",
+ "symfony/console": "^6.2|^7.0"
+ },
+ "conflict": {
+ "illuminate/console": ">=10.17.0 <10.25.0",
+ "laravel/framework": ">=10.17.0 <10.25.0"
+ },
+ "require-dev": {
+ "illuminate/collections": "^10.0|^11.0",
+ "mockery/mockery": "^1.5",
+ "pestphp/pest": "^2.3",
+ "phpstan/phpstan": "^1.11",
+ "phpstan/phpstan-mockery": "^1.1"
+ },
+ "suggest": {
+ "ext-pcntl": "Required for the spinner to be animated."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "0.3.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/helpers.php"
+ ],
+ "psr-4": {
+ "Laravel\\Prompts\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Add beautiful and user-friendly forms to your command-line applications.",
+ "support": {
+ "issues": "https://github.com/laravel/prompts/issues",
+ "source": "https://github.com/laravel/prompts/tree/v0.3.1"
+ },
+ "time": "2024-10-09T19:42:26+00:00"
+ },
+ {
+ "name": "laravel/serializable-closure",
+ "version": "v1.3.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/serializable-closure.git",
+ "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c",
+ "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.3|^8.0"
+ },
+ "require-dev": {
+ "illuminate/support": "^8.0|^9.0|^10.0|^11.0",
+ "nesbot/carbon": "^2.61|^3.0",
+ "pestphp/pest": "^1.21.3",
+ "phpstan/phpstan": "^1.8.2",
+ "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\SerializableClosure\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Nuno Maduro",
+ "email": "nuno@laravel.com"
+ }
+ ],
+ "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
+ "keywords": [
+ "closure",
+ "laravel",
+ "serializable"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/serializable-closure/issues",
+ "source": "https://github.com/laravel/serializable-closure"
+ },
+ "time": "2024-09-23T13:33:08+00:00"
+ },
+ {
+ "name": "league/commonmark",
+ "version": "2.5.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/commonmark.git",
+ "reference": "b650144166dfa7703e62a22e493b853b58d874b0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/b650144166dfa7703e62a22e493b853b58d874b0",
+ "reference": "b650144166dfa7703e62a22e493b853b58d874b0",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "league/config": "^1.1.1",
+ "php": "^7.4 || ^8.0",
+ "psr/event-dispatcher": "^1.0",
+ "symfony/deprecation-contracts": "^2.1 || ^3.0",
+ "symfony/polyfill-php80": "^1.16"
+ },
+ "require-dev": {
+ "cebe/markdown": "^1.0",
+ "commonmark/cmark": "0.31.1",
+ "commonmark/commonmark.js": "0.31.1",
+ "composer/package-versions-deprecated": "^1.8",
+ "embed/embed": "^4.4",
+ "erusev/parsedown": "^1.0",
+ "ext-json": "*",
+ "github/gfm": "0.29.0",
+ "michelf/php-markdown": "^1.4 || ^2.0",
+ "nyholm/psr7": "^1.5",
+ "phpstan/phpstan": "^1.8.2",
+ "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
+ "scrutinizer/ocular": "^1.8.1",
+ "symfony/finder": "^5.3 | ^6.0 || ^7.0",
+ "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 || ^7.0",
+ "unleashedtech/php-coding-standard": "^3.1.1",
+ "vimeo/psalm": "^4.24.0 || ^5.0.0"
+ },
+ "suggest": {
+ "symfony/yaml": "v2.3+ required if using the Front Matter extension"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\CommonMark\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Colin O'Dell",
+ "email": "colinodell@gmail.com",
+ "homepage": "https://www.colinodell.com",
+ "role": "Lead Developer"
+ }
+ ],
+ "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
+ "homepage": "https://commonmark.thephpleague.com",
+ "keywords": [
+ "commonmark",
+ "flavored",
+ "gfm",
+ "github",
+ "github-flavored",
+ "markdown",
+ "md",
+ "parser"
+ ],
+ "support": {
+ "docs": "https://commonmark.thephpleague.com/",
+ "forum": "https://github.com/thephpleague/commonmark/discussions",
+ "issues": "https://github.com/thephpleague/commonmark/issues",
+ "rss": "https://github.com/thephpleague/commonmark/releases.atom",
+ "source": "https://github.com/thephpleague/commonmark"
+ },
+ "funding": [
+ {
+ "url": "https://www.colinodell.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.paypal.me/colinpodell/10.00",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/colinodell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/league/commonmark",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-08-16T11:46:16+00:00"
+ },
+ {
+ "name": "league/config",
+ "version": "v1.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/config.git",
+ "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
+ "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
+ "shasum": ""
+ },
+ "require": {
+ "dflydev/dot-access-data": "^3.0.1",
+ "nette/schema": "^1.2",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.8.2",
+ "phpunit/phpunit": "^9.5.5",
+ "scrutinizer/ocular": "^1.8.1",
+ "unleashedtech/php-coding-standard": "^3.1",
+ "vimeo/psalm": "^4.7.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.2-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Config\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Colin O'Dell",
+ "email": "colinodell@gmail.com",
+ "homepage": "https://www.colinodell.com",
+ "role": "Lead Developer"
+ }
+ ],
+ "description": "Define configuration arrays with strict schemas and access values with dot notation",
+ "homepage": "https://config.thephpleague.com",
+ "keywords": [
+ "array",
+ "config",
+ "configuration",
+ "dot",
+ "dot-access",
+ "nested",
+ "schema"
+ ],
+ "support": {
+ "docs": "https://config.thephpleague.com/",
+ "issues": "https://github.com/thephpleague/config/issues",
+ "rss": "https://github.com/thephpleague/config/releases.atom",
+ "source": "https://github.com/thephpleague/config"
+ },
+ "funding": [
+ {
+ "url": "https://www.colinodell.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.paypal.me/colinpodell/10.00",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/colinodell",
+ "type": "github"
+ }
+ ],
+ "time": "2022-12-11T20:36:23+00:00"
+ },
+ {
+ "name": "league/flysystem",
+ "version": "3.29.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/flysystem.git",
+ "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319",
+ "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319",
+ "shasum": ""
+ },
+ "require": {
+ "league/flysystem-local": "^3.0.0",
+ "league/mime-type-detection": "^1.0.0",
+ "php": "^8.0.2"
+ },
+ "conflict": {
+ "async-aws/core": "<1.19.0",
+ "async-aws/s3": "<1.14.0",
+ "aws/aws-sdk-php": "3.209.31 || 3.210.0",
+ "guzzlehttp/guzzle": "<7.0",
+ "guzzlehttp/ringphp": "<1.1.1",
+ "phpseclib/phpseclib": "3.0.15",
+ "symfony/http-client": "<5.2"
+ },
+ "require-dev": {
+ "async-aws/s3": "^1.5 || ^2.0",
+ "async-aws/simple-s3": "^1.1 || ^2.0",
+ "aws/aws-sdk-php": "^3.295.10",
+ "composer/semver": "^3.0",
+ "ext-fileinfo": "*",
+ "ext-ftp": "*",
+ "ext-mongodb": "^1.3",
+ "ext-zip": "*",
+ "friendsofphp/php-cs-fixer": "^3.5",
+ "google/cloud-storage": "^1.23",
+ "guzzlehttp/psr7": "^2.6",
+ "microsoft/azure-storage-blob": "^1.1",
+ "mongodb/mongodb": "^1.2",
+ "phpseclib/phpseclib": "^3.0.36",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^9.5.11|^10.0",
+ "sabre/dav": "^4.6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "League\\Flysystem\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Frank de Jonge",
+ "email": "info@frankdejonge.nl"
+ }
+ ],
+ "description": "File storage abstraction for PHP",
+ "keywords": [
+ "WebDAV",
+ "aws",
+ "cloud",
+ "file",
+ "files",
+ "filesystem",
+ "filesystems",
+ "ftp",
+ "s3",
+ "sftp",
+ "storage"
+ ],
+ "support": {
+ "issues": "https://github.com/thephpleague/flysystem/issues",
+ "source": "https://github.com/thephpleague/flysystem/tree/3.29.1"
+ },
+ "time": "2024-10-08T08:58:34+00:00"
+ },
+ {
+ "name": "league/flysystem-local",
+ "version": "3.29.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/flysystem-local.git",
+ "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27",
+ "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27",
+ "shasum": ""
+ },
+ "require": {
+ "ext-fileinfo": "*",
+ "league/flysystem": "^3.0.0",
+ "league/mime-type-detection": "^1.0.0",
+ "php": "^8.0.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "League\\Flysystem\\Local\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Frank de Jonge",
+ "email": "info@frankdejonge.nl"
+ }
+ ],
+ "description": "Local filesystem adapter for Flysystem.",
+ "keywords": [
+ "Flysystem",
+ "file",
+ "files",
+ "filesystem",
+ "local"
+ ],
+ "support": {
+ "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0"
+ },
+ "time": "2024-08-09T21:24:39+00:00"
+ },
+ {
+ "name": "league/mime-type-detection",
+ "version": "1.16.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/mime-type-detection.git",
+ "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9",
+ "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-fileinfo": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "phpstan/phpstan": "^0.12.68",
+ "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "League\\MimeTypeDetection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Frank de Jonge",
+ "email": "info@frankdejonge.nl"
+ }
+ ],
+ "description": "Mime-type detection for Flysystem",
+ "support": {
+ "issues": "https://github.com/thephpleague/mime-type-detection/issues",
+ "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/frankdejonge",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/league/flysystem",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-21T08:32:55+00:00"
+ },
+ {
+ "name": "monolog/monolog",
+ "version": "3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f4393b648b78a5408747de94fca38beb5f7e9ef8",
+ "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/log": "^2.0 || ^3.0"
+ },
+ "provide": {
+ "psr/log-implementation": "3.0.0"
+ },
+ "require-dev": {
+ "aws/aws-sdk-php": "^3.0",
+ "doctrine/couchdb": "~1.0@dev",
+ "elasticsearch/elasticsearch": "^7 || ^8",
+ "ext-json": "*",
+ "graylog2/gelf-php": "^1.4.2 || ^2.0",
+ "guzzlehttp/guzzle": "^7.4.5",
+ "guzzlehttp/psr7": "^2.2",
+ "mongodb/mongodb": "^1.8",
+ "php-amqplib/php-amqplib": "~2.4 || ^3",
+ "phpstan/phpstan": "^1.9",
+ "phpstan/phpstan-deprecation-rules": "^1.0",
+ "phpstan/phpstan-strict-rules": "^1.4",
+ "phpunit/phpunit": "^10.5.17",
+ "predis/predis": "^1.1 || ^2",
+ "ruflin/elastica": "^7",
+ "symfony/mailer": "^5.4 || ^6",
+ "symfony/mime": "^5.4 || ^6"
+ },
+ "suggest": {
+ "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+ "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+ "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+ "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+ "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
+ "ext-mbstring": "Allow to work properly with unicode symbols",
+ "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+ "ext-openssl": "Required to send log messages using SSL",
+ "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
+ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+ "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+ "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+ "rollbar/rollbar": "Allow sending log messages to Rollbar",
+ "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Monolog\\": "src/Monolog"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "https://seld.be"
+ }
+ ],
+ "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+ "homepage": "https://github.com/Seldaek/monolog",
+ "keywords": [
+ "log",
+ "logging",
+ "psr-3"
+ ],
+ "support": {
+ "issues": "https://github.com/Seldaek/monolog/issues",
+ "source": "https://github.com/Seldaek/monolog/tree/3.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Seldaek",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-06-28T09:40:51+00:00"
+ },
+ {
+ "name": "nesbot/carbon",
+ "version": "3.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/briannesbitt/Carbon.git",
+ "reference": "bbd3eef89af8ba66a3aa7952b5439168fbcc529f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/bbd3eef89af8ba66a3aa7952b5439168fbcc529f",
+ "reference": "bbd3eef89af8ba66a3aa7952b5439168fbcc529f",
+ "shasum": ""
+ },
+ "require": {
+ "carbonphp/carbon-doctrine-types": "*",
+ "ext-json": "*",
+ "php": "^8.1",
+ "psr/clock": "^1.0",
+ "symfony/clock": "^6.3 || ^7.0",
+ "symfony/polyfill-mbstring": "^1.0",
+ "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0"
+ },
+ "provide": {
+ "psr/clock-implementation": "1.0"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^3.6.3 || ^4.0",
+ "doctrine/orm": "^2.15.2 || ^3.0",
+ "friendsofphp/php-cs-fixer": "^3.57.2",
+ "kylekatarnls/multi-tester": "^2.5.3",
+ "ondrejmirtes/better-reflection": "^6.25.0.4",
+ "phpmd/phpmd": "^2.15.0",
+ "phpstan/extension-installer": "^1.3.1",
+ "phpstan/phpstan": "^1.11.2",
+ "phpunit/phpunit": "^10.5.20",
+ "squizlabs/php_codesniffer": "^3.9.0"
+ },
+ "bin": [
+ "bin/carbon"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev",
+ "dev-2.x": "2.x-dev"
+ },
+ "laravel": {
+ "providers": [
+ "Carbon\\Laravel\\ServiceProvider"
+ ]
+ },
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Carbon\\": "src/Carbon/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Brian Nesbitt",
+ "email": "brian@nesbot.com",
+ "homepage": "https://markido.com"
+ },
+ {
+ "name": "kylekatarnls",
+ "homepage": "https://github.com/kylekatarnls"
+ }
+ ],
+ "description": "An API extension for DateTime that supports 281 different languages.",
+ "homepage": "https://carbon.nesbot.com",
+ "keywords": [
+ "date",
+ "datetime",
+ "time"
+ ],
+ "support": {
+ "docs": "https://carbon.nesbot.com/docs",
+ "issues": "https://github.com/briannesbitt/Carbon/issues",
+ "source": "https://github.com/briannesbitt/Carbon"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/kylekatarnls",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/Carbon#sponsor",
+ "type": "opencollective"
+ },
+ {
+ "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-08-19T06:22:39+00:00"
+ },
+ {
+ "name": "nette/schema",
+ "version": "v1.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nette/schema.git",
+ "reference": "da801d52f0354f70a638673c4a0f04e16529431d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
+ "reference": "da801d52f0354f70a638673c4a0f04e16529431d",
+ "shasum": ""
+ },
+ "require": {
+ "nette/utils": "^4.0",
+ "php": "8.1 - 8.4"
+ },
+ "require-dev": {
+ "nette/tester": "^2.5.2",
+ "phpstan/phpstan-nette": "^1.0",
+ "tracy/tracy": "^2.8"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.3-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause",
+ "GPL-2.0-only",
+ "GPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "David Grudl",
+ "homepage": "https://davidgrudl.com"
+ },
+ {
+ "name": "Nette Community",
+ "homepage": "https://nette.org/contributors"
+ }
+ ],
+ "description": "📐 Nette Schema: validating data structures against a given Schema.",
+ "homepage": "https://nette.org",
+ "keywords": [
+ "config",
+ "nette"
+ ],
+ "support": {
+ "issues": "https://github.com/nette/schema/issues",
+ "source": "https://github.com/nette/schema/tree/v1.3.2"
+ },
+ "time": "2024-10-06T23:10:23+00:00"
+ },
+ {
+ "name": "nette/utils",
+ "version": "v4.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nette/utils.git",
+ "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nette/utils/zipball/736c567e257dbe0fcf6ce81b4d6dbe05c6899f96",
+ "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96",
+ "shasum": ""
+ },
+ "require": {
+ "php": "8.0 - 8.4"
+ },
+ "conflict": {
+ "nette/finder": "<3",
+ "nette/schema": "<1.2.2"
+ },
+ "require-dev": {
+ "jetbrains/phpstorm-attributes": "dev-master",
+ "nette/tester": "^2.5",
+ "phpstan/phpstan": "^1.0",
+ "tracy/tracy": "^2.9"
+ },
+ "suggest": {
+ "ext-gd": "to use Image",
+ "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
+ "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
+ "ext-json": "to use Nette\\Utils\\Json",
+ "ext-mbstring": "to use Strings::lower() etc...",
+ "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause",
+ "GPL-2.0-only",
+ "GPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "David Grudl",
+ "homepage": "https://davidgrudl.com"
+ },
+ {
+ "name": "Nette Community",
+ "homepage": "https://nette.org/contributors"
+ }
+ ],
+ "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
+ "homepage": "https://nette.org",
+ "keywords": [
+ "array",
+ "core",
+ "datetime",
+ "images",
+ "json",
+ "nette",
+ "paginator",
+ "password",
+ "slugify",
+ "string",
+ "unicode",
+ "utf-8",
+ "utility",
+ "validation"
+ ],
+ "support": {
+ "issues": "https://github.com/nette/utils/issues",
+ "source": "https://github.com/nette/utils/tree/v4.0.5"
+ },
+ "time": "2024-08-07T15:39:19+00:00"
+ },
+ {
+ "name": "nunomaduro/termwind",
+ "version": "v2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nunomaduro/termwind.git",
+ "reference": "42c84e4e8090766bbd6445d06cd6e57650626ea3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/42c84e4e8090766bbd6445d06cd6e57650626ea3",
+ "reference": "42c84e4e8090766bbd6445d06cd6e57650626ea3",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": "^8.2",
+ "symfony/console": "^7.1.5"
+ },
+ "require-dev": {
+ "illuminate/console": "^11.28.0",
+ "laravel/pint": "^1.18.1",
+ "mockery/mockery": "^1.6.12",
+ "pestphp/pest": "^2.36.0",
+ "phpstan/phpstan": "^1.12.6",
+ "phpstan/phpstan-strict-rules": "^1.6.1",
+ "symfony/var-dumper": "^7.1.5",
+ "thecodingmachine/phpstan-strict-rules": "^1.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Termwind\\Laravel\\TermwindServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Functions.php"
+ ],
+ "psr-4": {
+ "Termwind\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "Its like Tailwind CSS, but for the console.",
+ "keywords": [
+ "cli",
+ "console",
+ "css",
+ "package",
+ "php",
+ "style"
+ ],
+ "support": {
+ "issues": "https://github.com/nunomaduro/termwind/issues",
+ "source": "https://github.com/nunomaduro/termwind/tree/v2.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/xiCO2k",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-15T16:15:16+00:00"
+ },
+ {
+ "name": "phpoption/phpoption",
+ "version": "1.9.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/schmittjoh/php-option.git",
+ "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54",
+ "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "1.9-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpOption\\": "src/PhpOption/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Johannes M. Schmitt",
+ "email": "schmittjoh@gmail.com",
+ "homepage": "https://github.com/schmittjoh"
+ },
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ }
+ ],
+ "description": "Option Type for PHP",
+ "keywords": [
+ "language",
+ "option",
+ "php",
+ "type"
+ ],
+ "support": {
+ "issues": "https://github.com/schmittjoh/php-option/issues",
+ "source": "https://github.com/schmittjoh/php-option/tree/1.9.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-07-20T21:41:07+00:00"
+ },
+ {
+ "name": "psr/clock",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/clock.git",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\Clock\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for reading the clock.",
+ "homepage": "https://github.com/php-fig/clock",
+ "keywords": [
+ "clock",
+ "now",
+ "psr",
+ "psr-20",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/clock/issues",
+ "source": "https://github.com/php-fig/clock/tree/1.0.0"
+ },
+ "time": "2022-11-25T14:36:26+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
+ },
+ {
+ "name": "psr/event-dispatcher",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/event-dispatcher.git",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\EventDispatcher\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Standard interfaces for event handling.",
+ "keywords": [
+ "events",
+ "psr",
+ "psr-14"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
+ "time": "2019-01-08T18:20:26+00:00"
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "psr/simple-cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/simple-cache.git",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\SimpleCache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for simple caching",
+ "keywords": [
+ "cache",
+ "caching",
+ "psr",
+ "psr-16",
+ "simple-cache"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+ },
+ "time": "2021-10-29T13:26:27+00:00"
+ },
+ {
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/getallheaders.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
+ "time": "2019-03-08T08:55:37+00:00"
+ },
+ {
+ "name": "ramsey/collection",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ramsey/collection.git",
+ "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
+ "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "captainhook/plugin-composer": "^5.3",
+ "ergebnis/composer-normalize": "^2.28.3",
+ "fakerphp/faker": "^1.21",
+ "hamcrest/hamcrest-php": "^2.0",
+ "jangregor/phpstan-prophecy": "^1.0",
+ "mockery/mockery": "^1.5",
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.3",
+ "phpcsstandards/phpcsutils": "^1.0.0-rc1",
+ "phpspec/prophecy-phpunit": "^2.0",
+ "phpstan/extension-installer": "^1.2",
+ "phpstan/phpstan": "^1.9",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-phpunit": "^1.3",
+ "phpunit/phpunit": "^9.5",
+ "psalm/plugin-mockery": "^1.1",
+ "psalm/plugin-phpunit": "^0.18.4",
+ "ramsey/coding-standard": "^2.0.3",
+ "ramsey/conventional-commits": "^1.3",
+ "vimeo/psalm": "^5.4"
+ },
+ "type": "library",
+ "extra": {
+ "captainhook": {
+ "force-install": true
+ },
+ "ramsey/conventional-commits": {
+ "configFile": "conventional-commits.json"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Ramsey\\Collection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ben Ramsey",
+ "email": "ben@benramsey.com",
+ "homepage": "https://benramsey.com"
+ }
+ ],
+ "description": "A PHP library for representing and manipulating collections.",
+ "keywords": [
+ "array",
+ "collection",
+ "hash",
+ "map",
+ "queue",
+ "set"
+ ],
+ "support": {
+ "issues": "https://github.com/ramsey/collection/issues",
+ "source": "https://github.com/ramsey/collection/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ramsey",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/ramsey/collection",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-12-31T21:50:55+00:00"
+ },
+ {
+ "name": "ramsey/uuid",
+ "version": "4.7.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ramsey/uuid.git",
+ "reference": "91039bc1faa45ba123c4328958e620d382ec7088"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088",
+ "reference": "91039bc1faa45ba123c4328958e620d382ec7088",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12",
+ "ext-json": "*",
+ "php": "^8.0",
+ "ramsey/collection": "^1.2 || ^2.0"
+ },
+ "replace": {
+ "rhumsaa/uuid": "self.version"
+ },
+ "require-dev": {
+ "captainhook/captainhook": "^5.10",
+ "captainhook/plugin-composer": "^5.3",
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
+ "doctrine/annotations": "^1.8",
+ "ergebnis/composer-normalize": "^2.15",
+ "mockery/mockery": "^1.3",
+ "paragonie/random-lib": "^2",
+ "php-mock/php-mock": "^2.2",
+ "php-mock/php-mock-mockery": "^1.3",
+ "php-parallel-lint/php-parallel-lint": "^1.1",
+ "phpbench/phpbench": "^1.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpunit/phpunit": "^8.5 || ^9",
+ "ramsey/composer-repl": "^1.4",
+ "slevomat/coding-standard": "^8.4",
+ "squizlabs/php_codesniffer": "^3.5",
+ "vimeo/psalm": "^4.9"
+ },
+ "suggest": {
+ "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
+ "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
+ "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
+ "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
+ "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
+ },
+ "type": "library",
+ "extra": {
+ "captainhook": {
+ "force-install": true
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Ramsey\\Uuid\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
+ "keywords": [
+ "guid",
+ "identifier",
+ "uuid"
+ ],
+ "support": {
+ "issues": "https://github.com/ramsey/uuid/issues",
+ "source": "https://github.com/ramsey/uuid/tree/4.7.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ramsey",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-04-27T21:32:50+00:00"
+ },
+ {
+ "name": "spatie/laravel-package-tools",
+ "version": "1.16.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-package-tools.git",
+ "reference": "c7413972cf22ffdff97b68499c22baa04eddb6a2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/c7413972cf22ffdff97b68499c22baa04eddb6a2",
+ "reference": "c7413972cf22ffdff97b68499c22baa04eddb6a2",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/contracts": "^9.28|^10.0|^11.0",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.5",
+ "orchestra/testbench": "^7.7|^8.0",
+ "pestphp/pest": "^1.22",
+ "phpunit/phpunit": "^9.5.24",
+ "spatie/pest-plugin-test-time": "^1.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\LaravelPackageTools\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Tools for creating Laravel packages",
+ "homepage": "https://github.com/spatie/laravel-package-tools",
+ "keywords": [
+ "laravel-package-tools",
+ "spatie"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/laravel-package-tools/issues",
+ "source": "https://github.com/spatie/laravel-package-tools/tree/1.16.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-27T18:56:10+00:00"
+ },
+ {
+ "name": "symfony/clock",
+ "version": "v7.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/clock.git",
+ "reference": "3dfc8b084853586de51dd1441c6242c76a28cbe7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/clock/zipball/3dfc8b084853586de51dd1441c6242c76a28cbe7",
+ "reference": "3dfc8b084853586de51dd1441c6242c76a28cbe7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/clock": "^1.0",
+ "symfony/polyfill-php83": "^1.28"
+ },
+ "provide": {
+ "psr/clock-implementation": "1.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/now.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\Clock\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Decouples applications from the system clock",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "clock",
+ "psr20",
+ "time"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/clock/tree/v7.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-31T14:57:53+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/0fa539d12b3ccf068a722bbbffa07ca7079af9ee",
+ "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^6.4|^7.0"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/dotenv": "<6.4",
+ "symfony/event-dispatcher": "<6.4",
+ "symfony/lock": "<6.4",
+ "symfony/process": "<6.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/event-dispatcher": "^6.4|^7.0",
+ "symfony/http-foundation": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/lock": "^6.4|^7.0",
+ "symfony/messenger": "^6.4|^7.0",
+ "symfony/process": "^6.4|^7.0",
+ "symfony/stopwatch": "^6.4|^7.0",
+ "symfony/var-dumper": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-20T08:28:38+00:00"
+ },
+ {
+ "name": "symfony/css-selector",
+ "version": "v7.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/css-selector.git",
+ "reference": "1c7cee86c6f812896af54434f8ce29c8d94f9ff4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/css-selector/zipball/1c7cee86c6f812896af54434f8ce29c8d94f9ff4",
+ "reference": "1c7cee86c6f812896af54434f8ce29c8d94f9ff4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\CssSelector\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Jean-François Simon",
+ "email": "jeanfrancois.simon@sensiolabs.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Converts CSS selectors to XPath expressions",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/css-selector/tree/v7.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-31T14:57:53+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
+ "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.5-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-04-18T09:32:20+00:00"
+ },
+ {
+ "name": "symfony/error-handler",
+ "version": "v7.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/error-handler.git",
+ "reference": "432bb369952795c61ca1def65e078c4a80dad13c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/error-handler/zipball/432bb369952795c61ca1def65e078c4a80dad13c",
+ "reference": "432bb369952795c61ca1def65e078c4a80dad13c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^1|^2|^3",
+ "symfony/var-dumper": "^6.4|^7.0"
+ },
+ "conflict": {
+ "symfony/deprecation-contracts": "<2.5",
+ "symfony/http-kernel": "<6.4"
+ },
+ "require-dev": {
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/serializer": "^6.4|^7.0"
+ },
+ "bin": [
+ "Resources/bin/patch-type-declarations"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\ErrorHandler\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides tools to manage errors and ease debugging PHP code",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/error-handler/tree/v7.1.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-07-26T13:02:51+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher",
+ "version": "v7.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher.git",
+ "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7",
+ "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/event-dispatcher-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/service-contracts": "<2.5"
+ },
+ "provide": {
+ "psr/event-dispatcher-implementation": "1.0",
+ "symfony/event-dispatcher-implementation": "2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/error-handler": "^6.4|^7.0",
+ "symfony/expression-language": "^6.4|^7.0",
+ "symfony/http-foundation": "^6.4|^7.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/stopwatch": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\EventDispatcher\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher/tree/v7.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-31T14:57:53+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher-contracts",
+ "version": "v3.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+ "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50",
+ "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/event-dispatcher": "^1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.5-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\EventDispatcher\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to dispatching event",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-04-18T09:32:20+00:00"
+ },
+ {
+ "name": "symfony/finder",
+ "version": "v7.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/finder.git",
+ "reference": "d95bbf319f7d052082fb7af147e0f835a695e823"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/d95bbf319f7d052082fb7af147e0f835a695e823",
+ "reference": "d95bbf319f7d052082fb7af147e0f835a695e823",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "symfony/filesystem": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Finder\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Finds files and directories via an intuitive fluent interface",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v7.1.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-08-13T14:28:19+00:00"
+ },
+ {
+ "name": "symfony/http-foundation",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-foundation.git",
+ "reference": "e30ef73b1e44eea7eb37ba69600a354e553f694b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e30ef73b1e44eea7eb37ba69600a354e553f694b",
+ "reference": "e30ef73b1e44eea7eb37ba69600a354e553f694b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-mbstring": "~1.1",
+ "symfony/polyfill-php83": "^1.27"
+ },
+ "conflict": {
+ "doctrine/dbal": "<3.6",
+ "symfony/cache": "<6.4"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^3.6|^4",
+ "predis/predis": "^1.1|^2.0",
+ "symfony/cache": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/expression-language": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/mime": "^6.4|^7.0",
+ "symfony/rate-limiter": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpFoundation\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Defines an object-oriented layer for the HTTP specification",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/http-foundation/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-20T08:28:38+00:00"
+ },
+ {
+ "name": "symfony/http-kernel",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-kernel.git",
+ "reference": "44204d96150a9df1fc57601ec933d23fefc2d65b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/44204d96150a9df1fc57601ec933d23fefc2d65b",
+ "reference": "44204d96150a9df1fc57601ec933d23fefc2d65b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^1|^2|^3",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/error-handler": "^6.4|^7.0",
+ "symfony/event-dispatcher": "^6.4|^7.0",
+ "symfony/http-foundation": "^6.4|^7.0",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "conflict": {
+ "symfony/browser-kit": "<6.4",
+ "symfony/cache": "<6.4",
+ "symfony/config": "<6.4",
+ "symfony/console": "<6.4",
+ "symfony/dependency-injection": "<6.4",
+ "symfony/doctrine-bridge": "<6.4",
+ "symfony/form": "<6.4",
+ "symfony/http-client": "<6.4",
+ "symfony/http-client-contracts": "<2.5",
+ "symfony/mailer": "<6.4",
+ "symfony/messenger": "<6.4",
+ "symfony/translation": "<6.4",
+ "symfony/translation-contracts": "<2.5",
+ "symfony/twig-bridge": "<6.4",
+ "symfony/validator": "<6.4",
+ "symfony/var-dumper": "<6.4",
+ "twig/twig": "<3.0.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/cache": "^1.0|^2.0|^3.0",
+ "symfony/browser-kit": "^6.4|^7.0",
+ "symfony/clock": "^6.4|^7.0",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/console": "^6.4|^7.0",
+ "symfony/css-selector": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/dom-crawler": "^6.4|^7.0",
+ "symfony/expression-language": "^6.4|^7.0",
+ "symfony/finder": "^6.4|^7.0",
+ "symfony/http-client-contracts": "^2.5|^3",
+ "symfony/process": "^6.4|^7.0",
+ "symfony/property-access": "^7.1",
+ "symfony/routing": "^6.4|^7.0",
+ "symfony/serializer": "^7.1",
+ "symfony/stopwatch": "^6.4|^7.0",
+ "symfony/translation": "^6.4|^7.0",
+ "symfony/translation-contracts": "^2.5|^3",
+ "symfony/uid": "^6.4|^7.0",
+ "symfony/validator": "^6.4|^7.0",
+ "symfony/var-dumper": "^6.4|^7.0",
+ "symfony/var-exporter": "^6.4|^7.0",
+ "twig/twig": "^3.0.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpKernel\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides a structured process for converting a Request into a Response",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/http-kernel/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-21T06:09:21+00:00"
+ },
+ {
+ "name": "symfony/mailer",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/mailer.git",
+ "reference": "bbf21460c56f29810da3df3e206e38dfbb01e80b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/bbf21460c56f29810da3df3e206e38dfbb01e80b",
+ "reference": "bbf21460c56f29810da3df3e206e38dfbb01e80b",
+ "shasum": ""
+ },
+ "require": {
+ "egulias/email-validator": "^2.1.10|^3|^4",
+ "php": ">=8.2",
+ "psr/event-dispatcher": "^1",
+ "psr/log": "^1|^2|^3",
+ "symfony/event-dispatcher": "^6.4|^7.0",
+ "symfony/mime": "^6.4|^7.0",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "symfony/http-client-contracts": "<2.5",
+ "symfony/http-kernel": "<6.4",
+ "symfony/messenger": "<6.4",
+ "symfony/mime": "<6.4",
+ "symfony/twig-bridge": "<6.4"
+ },
+ "require-dev": {
+ "symfony/console": "^6.4|^7.0",
+ "symfony/http-client": "^6.4|^7.0",
+ "symfony/messenger": "^6.4|^7.0",
+ "symfony/twig-bridge": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Mailer\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Helps sending emails",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/mailer/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-08T12:32:26+00:00"
+ },
+ {
+ "name": "symfony/mime",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/mime.git",
+ "reference": "711d2e167e8ce65b05aea6b258c449671cdd38ff"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/711d2e167e8ce65b05aea6b258c449671cdd38ff",
+ "reference": "711d2e167e8ce65b05aea6b258c449671cdd38ff",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-intl-idn": "^1.10",
+ "symfony/polyfill-mbstring": "^1.0"
+ },
+ "conflict": {
+ "egulias/email-validator": "~3.0.0",
+ "phpdocumentor/reflection-docblock": "<3.2.2",
+ "phpdocumentor/type-resolver": "<1.4.0",
+ "symfony/mailer": "<6.4",
+ "symfony/serializer": "<6.4.3|>7.0,<7.0.3"
+ },
+ "require-dev": {
+ "egulias/email-validator": "^2.1.10|^3.1|^4",
+ "league/html-to-markdown": "^5.0",
+ "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/process": "^6.4|^7.0",
+ "symfony/property-access": "^6.4|^7.0",
+ "symfony/property-info": "^6.4|^7.0",
+ "symfony/serializer": "^6.4.3|^7.0.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Mime\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Allows manipulating MIME messages",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "mime",
+ "mime-type"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/mime/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-20T08:28:38+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
+ "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-idn",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-idn.git",
+ "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773",
+ "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2",
+ "symfony/polyfill-intl-normalizer": "^1.10"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Idn\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Laurent Bassin",
+ "email": "laurent@bassin.info"
+ },
+ {
+ "name": "Trevor Rowbotham",
+ "email": "trevor.rowbotham@pm.me"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "idn",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
+ "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php80",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php80.git",
+ "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
+ "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php80\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ion Bazan",
+ "email": "ion.bazan@gmail.com"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php83",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php83.git",
+ "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491",
+ "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php83\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-uuid",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-uuid.git",
+ "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+ "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-uuid": "*"
+ },
+ "suggest": {
+ "ext-uuid": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Uuid\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Grégoire Pineau",
+ "email": "lyrixx@lyrixx.info"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for uuid functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "uuid"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/process",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/process.git",
+ "reference": "5c03ee6369281177f07f7c68252a280beccba847"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847",
+ "reference": "5c03ee6369281177f07f7c68252a280beccba847",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Process\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Executes commands in sub-processes",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-19T21:48:23+00:00"
+ },
+ {
+ "name": "symfony/routing",
+ "version": "v7.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/routing.git",
+ "reference": "1500aee0094a3ce1c92626ed8cf3c2037e86f5a7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/1500aee0094a3ce1c92626ed8cf3c2037e86f5a7",
+ "reference": "1500aee0094a3ce1c92626ed8cf3c2037e86f5a7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "symfony/config": "<6.4",
+ "symfony/dependency-injection": "<6.4",
+ "symfony/yaml": "<6.4"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/expression-language": "^6.4|^7.0",
+ "symfony/http-foundation": "^6.4|^7.0",
+ "symfony/yaml": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Routing\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Maps an HTTP request to a set of configuration variables",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "router",
+ "routing",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/routing/tree/v7.1.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-08-29T08:16:25+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
+ "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.5-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-04-18T09:32:20+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/d66f9c343fa894ec2037cc928381df90a7ad4306",
+ "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/emoji": "^7.1",
+ "symfony/error-handler": "^6.4|^7.0",
+ "symfony/http-client": "^6.4|^7.0",
+ "symfony/intl": "^6.4|^7.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-20T08:28:38+00:00"
+ },
+ {
+ "name": "symfony/translation",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/translation.git",
+ "reference": "235535e3f84f3dfbdbde0208ede6ca75c3a489ea"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/235535e3f84f3dfbdbde0208ede6ca75c3a489ea",
+ "reference": "235535e3f84f3dfbdbde0208ede6ca75c3a489ea",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/translation-contracts": "^2.5|^3.0"
+ },
+ "conflict": {
+ "symfony/config": "<6.4",
+ "symfony/console": "<6.4",
+ "symfony/dependency-injection": "<6.4",
+ "symfony/http-client-contracts": "<2.5",
+ "symfony/http-kernel": "<6.4",
+ "symfony/service-contracts": "<2.5",
+ "symfony/twig-bundle": "<6.4",
+ "symfony/yaml": "<6.4"
+ },
+ "provide": {
+ "symfony/translation-implementation": "2.3|3.0"
+ },
+ "require-dev": {
+ "nikic/php-parser": "^4.18|^5.0",
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/console": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/finder": "^6.4|^7.0",
+ "symfony/http-client-contracts": "^2.5|^3.0",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/intl": "^6.4|^7.0",
+ "symfony/polyfill-intl-icu": "^1.21",
+ "symfony/routing": "^6.4|^7.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/yaml": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\Translation\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides tools to internationalize your application",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/translation/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-16T06:30:38+00:00"
+ },
+ {
+ "name": "symfony/translation-contracts",
+ "version": "v3.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/translation-contracts.git",
+ "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a",
+ "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.5-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Translation\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to translation",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-04-18T09:32:20+00:00"
+ },
+ {
+ "name": "symfony/uid",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/uid.git",
+ "reference": "8c7bb8acb933964055215d89f9a9871df0239317"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/uid/zipball/8c7bb8acb933964055215d89f9a9871df0239317",
+ "reference": "8c7bb8acb933964055215d89f9a9871df0239317",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-uuid": "^1.15"
+ },
+ "require-dev": {
+ "symfony/console": "^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Uid\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Grégoire Pineau",
+ "email": "lyrixx@lyrixx.info"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to generate and represent UIDs",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "UID",
+ "ulid",
+ "uuid"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/uid/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-17T09:16:35+00:00"
+ },
+ {
+ "name": "symfony/var-dumper",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/var-dumper.git",
+ "reference": "e20e03889539fd4e4211e14d2179226c513c010d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e20e03889539fd4e4211e14d2179226c513c010d",
+ "reference": "e20e03889539fd4e4211e14d2179226c513c010d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/console": "<6.4"
+ },
+ "require-dev": {
+ "ext-iconv": "*",
+ "symfony/console": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0",
+ "symfony/process": "^6.4|^7.0",
+ "symfony/uid": "^6.4|^7.0",
+ "twig/twig": "^3.0.4"
+ },
+ "bin": [
+ "Resources/bin/var-dump-server"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions/dump.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\VarDumper\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides mechanisms for walking through any arbitrary PHP variable",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "debug",
+ "dump"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/var-dumper/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-16T10:07:02+00:00"
+ },
+ {
+ "name": "tijsverkoyen/css-to-inline-styles",
+ "version": "v2.2.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
+ "reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/83ee6f38df0a63106a9e4536e3060458b74ccedb",
+ "reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "php": "^5.5 || ^7.0 || ^8.0",
+ "symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^7.5 || ^8.5.21 || ^9.5.10"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "TijsVerkoyen\\CssToInlineStyles\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Tijs Verkoyen",
+ "email": "css_to_inline_styles@verkoyen.eu",
+ "role": "Developer"
+ }
+ ],
+ "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.",
+ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
+ "support": {
+ "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
+ "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.2.7"
+ },
+ "time": "2023-12-08T13:03:43+00:00"
+ },
+ {
+ "name": "vlucas/phpdotenv",
+ "version": "v5.6.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/vlucas/phpdotenv.git",
+ "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2",
+ "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2",
+ "shasum": ""
+ },
+ "require": {
+ "ext-pcre": "*",
+ "graham-campbell/result-type": "^1.1.3",
+ "php": "^7.2.5 || ^8.0",
+ "phpoption/phpoption": "^1.9.3",
+ "symfony/polyfill-ctype": "^1.24",
+ "symfony/polyfill-mbstring": "^1.24",
+ "symfony/polyfill-php80": "^1.24"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "ext-filter": "*",
+ "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
+ },
+ "suggest": {
+ "ext-filter": "Required to use the boolean validator."
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "5.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Dotenv\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Vance Lucas",
+ "email": "vance@vancelucas.com",
+ "homepage": "https://github.com/vlucas"
+ }
+ ],
+ "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
+ "keywords": [
+ "dotenv",
+ "env",
+ "environment"
+ ],
+ "support": {
+ "issues": "https://github.com/vlucas/phpdotenv/issues",
+ "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-07-20T21:52:34+00:00"
+ },
+ {
+ "name": "voku/portable-ascii",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/voku/portable-ascii.git",
+ "reference": "b56450eed252f6801410d810c8e1727224ae0743"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b56450eed252f6801410d810c8e1727224ae0743",
+ "reference": "b56450eed252f6801410d810c8e1727224ae0743",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0"
+ },
+ "suggest": {
+ "ext-intl": "Use Intl for transliterator_transliterate() support"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "voku\\": "src/voku/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Lars Moelleken",
+ "homepage": "http://www.moelleken.org/"
+ }
+ ],
+ "description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
+ "homepage": "https://github.com/voku/portable-ascii",
+ "keywords": [
+ "ascii",
+ "clean",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/voku/portable-ascii/issues",
+ "source": "https://github.com/voku/portable-ascii/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.me/moelleken",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/voku",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/portable-ascii",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://www.patreon.com/voku",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-03-08T17:03:00+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "1.11.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991",
+ "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<0.12.20",
+ "vimeo/psalm": "<4.6.1 || 4.6.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5.13"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.10-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/1.11.0"
+ },
+ "time": "2022-06-03T18:03:27+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "brianium/paratest",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paratestphp/paratest.git",
+ "reference": "cf16fcbb9b8107a7df6b97e497fc91e819774d8b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/cf16fcbb9b8107a7df6b97e497fc91e819774d8b",
+ "reference": "cf16fcbb9b8107a7df6b97e497fc91e819774d8b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-simplexml": "*",
+ "fidry/cpu-core-counter": "^1.2.0",
+ "jean85/pretty-package-versions": "^2.0.6",
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0",
+ "phpunit/php-code-coverage": "^10.1.16",
+ "phpunit/php-file-iterator": "^4.1.0",
+ "phpunit/php-timer": "^6.0.0",
+ "phpunit/phpunit": "^10.5.36",
+ "sebastian/environment": "^6.1.0",
+ "symfony/console": "^6.4.7 || ^7.1.5",
+ "symfony/process": "^6.4.7 || ^7.1.5"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^12.0.0",
+ "ext-pcov": "*",
+ "ext-posix": "*",
+ "phpstan/phpstan": "^1.12.6",
+ "phpstan/phpstan-deprecation-rules": "^1.2.1",
+ "phpstan/phpstan-phpunit": "^1.4.0",
+ "phpstan/phpstan-strict-rules": "^1.6.1",
+ "squizlabs/php_codesniffer": "^3.10.3",
+ "symfony/filesystem": "^6.4.3 || ^7.1.5"
+ },
+ "bin": [
+ "bin/paratest",
+ "bin/paratest_for_phpstorm"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ParaTest\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Brian Scaturro",
+ "email": "scaturrob@gmail.com",
+ "role": "Developer"
+ },
+ {
+ "name": "Filippo Tessarotto",
+ "email": "zoeslam@gmail.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "Parallel testing for PHP",
+ "homepage": "https://github.com/paratestphp/paratest",
+ "keywords": [
+ "concurrent",
+ "parallel",
+ "phpunit",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/paratestphp/paratest/issues",
+ "source": "https://github.com/paratestphp/paratest/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/Slamdunk",
+ "type": "github"
+ },
+ {
+ "url": "https://paypal.me/filippotessarotto",
+ "type": "paypal"
+ }
+ ],
+ "time": "2024-10-15T12:45:19+00:00"
+ },
+ {
+ "name": "composer/semver",
+ "version": "3.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/semver.git",
+ "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12",
+ "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.2 || ^7.0 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.11",
+ "symfony/phpunit-bridge": "^3 || ^7"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Semver\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nils Adermann",
+ "email": "naderman@naderman.de",
+ "homepage": "http://www.naderman.de"
+ },
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ },
+ {
+ "name": "Rob Bast",
+ "email": "rob.bast@gmail.com",
+ "homepage": "http://robbast.nl"
+ }
+ ],
+ "description": "Semver library that offers utilities, version constraint parsing and validation.",
+ "keywords": [
+ "semantic",
+ "semver",
+ "validation",
+ "versioning"
+ ],
+ "support": {
+ "irc": "ircs://irc.libera.chat:6697/composer",
+ "issues": "https://github.com/composer/semver/issues",
+ "source": "https://github.com/composer/semver/tree/3.4.3"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-19T14:15:21+00:00"
+ },
+ {
+ "name": "doctrine/deprecations",
+ "version": "1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/deprecations.git",
+ "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab",
+ "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9",
+ "phpstan/phpstan": "1.4.10 || 1.10.15",
+ "phpstan/phpstan-phpunit": "^1.0",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+ "psalm/plugin-phpunit": "0.18.4",
+ "psr/log": "^1 || ^2 || ^3",
+ "vimeo/psalm": "4.30.0 || 5.12.0"
+ },
+ "suggest": {
+ "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+ "homepage": "https://www.doctrine-project.org/",
+ "support": {
+ "issues": "https://github.com/doctrine/deprecations/issues",
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.3"
+ },
+ "time": "2024-01-30T19:34:25+00:00"
+ },
+ {
+ "name": "fakerphp/faker",
+ "version": "v1.23.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/FakerPHP/Faker.git",
+ "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/bfb4fe148adbf78eff521199619b93a52ae3554b",
+ "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "psr/container": "^1.0 || ^2.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "conflict": {
+ "fzaninotto/faker": "*"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.4.1",
+ "doctrine/persistence": "^1.3 || ^2.0",
+ "ext-intl": "*",
+ "phpunit/phpunit": "^9.5.26",
+ "symfony/phpunit-bridge": "^5.4.16"
+ },
+ "suggest": {
+ "doctrine/orm": "Required to use Faker\\ORM\\Doctrine",
+ "ext-curl": "Required by Faker\\Provider\\Image to download images.",
+ "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
+ "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
+ "ext-mbstring": "Required for multibyte Unicode string functionality."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Faker\\": "src/Faker/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "François Zaninotto"
+ }
+ ],
+ "description": "Faker is a PHP library that generates fake data for you.",
+ "keywords": [
+ "data",
+ "faker",
+ "fixtures"
+ ],
+ "support": {
+ "issues": "https://github.com/FakerPHP/Faker/issues",
+ "source": "https://github.com/FakerPHP/Faker/tree/v1.23.1"
+ },
+ "time": "2024-01-02T13:46:09+00:00"
+ },
+ {
+ "name": "fidry/cpu-core-counter",
+ "version": "1.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/cpu-core-counter.git",
+ "reference": "8520451a140d3f46ac33042715115e290cf5785f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f",
+ "reference": "8520451a140d3f46ac33042715115e290cf5785f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "fidry/makefile": "^0.2.0",
+ "fidry/php-cs-fixer-config": "^1.1.2",
+ "phpstan/extension-installer": "^1.2.0",
+ "phpstan/phpstan": "^1.9.2",
+ "phpstan/phpstan-deprecation-rules": "^1.0.0",
+ "phpstan/phpstan-phpunit": "^1.2.2",
+ "phpstan/phpstan-strict-rules": "^1.4.4",
+ "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "webmozarts/strict-phpunit": "^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Fidry\\CpuCoreCounter\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Théo FIDRY",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Tiny utility to get the number of CPU cores.",
+ "keywords": [
+ "CPU",
+ "core"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-06T10:04:20+00:00"
+ },
+ {
+ "name": "filp/whoops",
+ "version": "2.16.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/filp/whoops.git",
+ "reference": "befcdc0e5dce67252aa6322d82424be928214fa2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/filp/whoops/zipball/befcdc0e5dce67252aa6322d82424be928214fa2",
+ "reference": "befcdc0e5dce67252aa6322d82424be928214fa2",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0",
+ "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.0",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
+ "symfony/var-dumper": "^4.0 || ^5.0"
+ },
+ "suggest": {
+ "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
+ "whoops/soap": "Formats errors as SOAP responses"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Whoops\\": "src/Whoops/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Filipe Dobreira",
+ "homepage": "https://github.com/filp",
+ "role": "Developer"
+ }
+ ],
+ "description": "php error handling for cool kids",
+ "homepage": "https://filp.github.io/whoops/",
+ "keywords": [
+ "error",
+ "exception",
+ "handling",
+ "library",
+ "throwable",
+ "whoops"
+ ],
+ "support": {
+ "issues": "https://github.com/filp/whoops/issues",
+ "source": "https://github.com/filp/whoops/tree/2.16.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/denis-sokolov",
+ "type": "github"
+ }
+ ],
+ "time": "2024-09-25T12:00:00+00:00"
+ },
+ {
+ "name": "hamcrest/hamcrest-php",
+ "version": "v2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/hamcrest/hamcrest-php.git",
+ "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
+ "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3|^7.0|^8.0"
+ },
+ "replace": {
+ "cordoval/hamcrest-php": "*",
+ "davedevelopment/hamcrest-php": "*",
+ "kodova/hamcrest-php": "*"
+ },
+ "require-dev": {
+ "phpunit/php-file-iterator": "^1.4 || ^2.0",
+ "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "hamcrest"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "This is the PHP port of Hamcrest Matchers",
+ "keywords": [
+ "test"
+ ],
+ "support": {
+ "issues": "https://github.com/hamcrest/hamcrest-php/issues",
+ "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1"
+ },
+ "time": "2020-07-09T08:09:16+00:00"
+ },
+ {
+ "name": "jean85/pretty-package-versions",
+ "version": "2.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Jean85/pretty-package-versions.git",
+ "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/f9fdd29ad8e6d024f52678b570e5593759b550b4",
+ "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.0.0",
+ "php": "^7.1|^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "jean85/composer-provided-replaced-stub-package": "^1.0",
+ "phpstan/phpstan": "^1.4",
+ "phpunit/phpunit": "^7.5|^8.5|^9.4",
+ "vimeo/psalm": "^4.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Jean85\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Alessandro Lai",
+ "email": "alessandro.lai85@gmail.com"
+ }
+ ],
+ "description": "A library to get pretty versions strings of installed dependencies",
+ "keywords": [
+ "composer",
+ "package",
+ "release",
+ "versions"
+ ],
+ "support": {
+ "issues": "https://github.com/Jean85/pretty-package-versions/issues",
+ "source": "https://github.com/Jean85/pretty-package-versions/tree/2.0.6"
+ },
+ "time": "2024-03-08T09:58:59+00:00"
+ },
+ {
+ "name": "larastan/larastan",
+ "version": "v2.9.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/larastan/larastan.git",
+ "reference": "148faa138f0d8acb7d85f4a55693d3e13b6048d2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/larastan/larastan/zipball/148faa138f0d8acb7d85f4a55693d3e13b6048d2",
+ "reference": "148faa138f0d8acb7d85f4a55693d3e13b6048d2",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "illuminate/console": "^9.52.16 || ^10.28.0 || ^11.16",
+ "illuminate/container": "^9.52.16 || ^10.28.0 || ^11.16",
+ "illuminate/contracts": "^9.52.16 || ^10.28.0 || ^11.16",
+ "illuminate/database": "^9.52.16 || ^10.28.0 || ^11.16",
+ "illuminate/http": "^9.52.16 || ^10.28.0 || ^11.16",
+ "illuminate/pipeline": "^9.52.16 || ^10.28.0 || ^11.16",
+ "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.16",
+ "php": "^8.0.2",
+ "phpmyadmin/sql-parser": "^5.9.0",
+ "phpstan/phpstan": "^1.12.5"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^12.0",
+ "nikic/php-parser": "^4.19.1",
+ "orchestra/canvas": "^7.11.1 || ^8.11.0 || ^9.0.2",
+ "orchestra/testbench": "^7.33.0 || ^8.13.0 || ^9.0.3",
+ "phpstan/phpstan-deprecation-rules": "^1.2",
+ "phpunit/phpunit": "^9.6.13 || ^10.5.16"
+ },
+ "suggest": {
+ "orchestra/testbench": "Using Larastan for analysing a package needs Testbench"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ },
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Larastan\\Larastan\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Can Vural",
+ "email": "can9119@gmail.com"
+ },
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel",
+ "keywords": [
+ "PHPStan",
+ "code analyse",
+ "code analysis",
+ "larastan",
+ "laravel",
+ "package",
+ "php",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/larastan/larastan/issues",
+ "source": "https://github.com/larastan/larastan/tree/v2.9.9"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/canvural",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/nunomaduro",
+ "type": "patreon"
+ }
+ ],
+ "time": "2024-10-15T19:41:22+00:00"
+ },
+ {
+ "name": "laravel/pint",
+ "version": "v1.18.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/pint.git",
+ "reference": "35c00c05ec43e6b46d295efc0f4386ceb30d50d9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/35c00c05ec43e6b46d295efc0f4386ceb30d50d9",
+ "reference": "35c00c05ec43e6b46d295efc0f4386ceb30d50d9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "ext-xml": "*",
+ "php": "^8.1.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.64.0",
+ "illuminate/view": "^10.48.20",
+ "larastan/larastan": "^2.9.8",
+ "laravel-zero/framework": "^10.4.0",
+ "mockery/mockery": "^1.6.12",
+ "nunomaduro/termwind": "^1.15.1",
+ "pestphp/pest": "^2.35.1"
+ },
+ "bin": [
+ "builds/pint"
+ ],
+ "type": "project",
+ "autoload": {
+ "psr-4": {
+ "App\\": "app/",
+ "Database\\Seeders\\": "database/seeders/",
+ "Database\\Factories\\": "database/factories/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "An opinionated code formatter for PHP.",
+ "homepage": "https://laravel.com",
+ "keywords": [
+ "format",
+ "formatter",
+ "lint",
+ "linter",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/pint/issues",
+ "source": "https://github.com/laravel/pint"
+ },
+ "time": "2024-09-24T17:22:50+00:00"
+ },
+ {
+ "name": "laravel/tinker",
+ "version": "v2.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/tinker.git",
+ "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/tinker/zipball/ba4d51eb56de7711b3a37d63aa0643e99a339ae5",
+ "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
+ "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
+ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
+ "php": "^7.2.5|^8.0",
+ "psy/psysh": "^0.11.1|^0.12.0",
+ "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "~1.3.3|^1.4.2",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^8.5.8|^9.3.3"
+ },
+ "suggest": {
+ "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0)."
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Tinker\\TinkerServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Tinker\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Powerful REPL for the Laravel framework.",
+ "keywords": [
+ "REPL",
+ "Tinker",
+ "laravel",
+ "psysh"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/tinker/issues",
+ "source": "https://github.com/laravel/tinker/tree/v2.10.0"
+ },
+ "time": "2024-09-23T13:32:56+00:00"
+ },
+ {
+ "name": "mockery/mockery",
+ "version": "1.6.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mockery/mockery.git",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "shasum": ""
+ },
+ "require": {
+ "hamcrest/hamcrest-php": "^2.0.1",
+ "lib-pcre": ">=7.0",
+ "php": ">=7.3"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.6.17",
+ "symplify/easy-coding-standard": "^12.1.14"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/helpers.php",
+ "library/Mockery.php"
+ ],
+ "psr-4": {
+ "Mockery\\": "library/Mockery"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Pádraic Brady",
+ "email": "padraic.brady@gmail.com",
+ "homepage": "https://github.com/padraic",
+ "role": "Author"
+ },
+ {
+ "name": "Dave Marshall",
+ "email": "dave.marshall@atstsolutions.co.uk",
+ "homepage": "https://davedevelopment.co.uk",
+ "role": "Developer"
+ },
+ {
+ "name": "Nathanael Esayeas",
+ "email": "nathanael.esayeas@protonmail.com",
+ "homepage": "https://github.com/ghostwriter",
+ "role": "Lead Developer"
+ }
+ ],
+ "description": "Mockery is a simple yet flexible PHP mock object framework",
+ "homepage": "https://github.com/mockery/mockery",
+ "keywords": [
+ "BDD",
+ "TDD",
+ "library",
+ "mock",
+ "mock objects",
+ "mockery",
+ "stub",
+ "test",
+ "test double",
+ "testing"
+ ],
+ "support": {
+ "docs": "https://docs.mockery.io/",
+ "issues": "https://github.com/mockery/mockery/issues",
+ "rss": "https://github.com/mockery/mockery/releases.atom",
+ "security": "https://github.com/mockery/mockery/security/advisories",
+ "source": "https://github.com/mockery/mockery"
+ },
+ "time": "2024-05-16T03:13:13+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.12.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
+ "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-06-12T14:39:25+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b",
+ "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1"
+ },
+ "time": "2024-10-08T18:51:32+00:00"
+ },
+ {
+ "name": "nunomaduro/collision",
+ "version": "v8.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nunomaduro/collision.git",
+ "reference": "f5c101b929c958e849a633283adff296ed5f38f5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f5c101b929c958e849a633283adff296ed5f38f5",
+ "reference": "f5c101b929c958e849a633283adff296ed5f38f5",
+ "shasum": ""
+ },
+ "require": {
+ "filp/whoops": "^2.16.0",
+ "nunomaduro/termwind": "^2.1.0",
+ "php": "^8.2.0",
+ "symfony/console": "^7.1.5"
+ },
+ "conflict": {
+ "laravel/framework": "<11.0.0 || >=12.0.0",
+ "phpunit/phpunit": "<10.5.1 || >=12.0.0"
+ },
+ "require-dev": {
+ "larastan/larastan": "^2.9.8",
+ "laravel/framework": "^11.28.0",
+ "laravel/pint": "^1.18.1",
+ "laravel/sail": "^1.36.0",
+ "laravel/sanctum": "^4.0.3",
+ "laravel/tinker": "^2.10.0",
+ "orchestra/testbench-core": "^9.5.3",
+ "pestphp/pest": "^2.36.0 || ^3.4.0",
+ "sebastian/environment": "^6.1.0 || ^7.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-8.x": "8.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "./src/Adapters/Phpunit/Autoload.php"
+ ],
+ "psr-4": {
+ "NunoMaduro\\Collision\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "Cli error handling for console/command-line PHP applications.",
+ "keywords": [
+ "artisan",
+ "cli",
+ "command-line",
+ "console",
+ "error",
+ "handling",
+ "laravel",
+ "laravel-zero",
+ "php",
+ "symfony"
+ ],
+ "support": {
+ "issues": "https://github.com/nunomaduro/collision/issues",
+ "source": "https://github.com/nunomaduro/collision"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/nunomaduro",
+ "type": "patreon"
+ }
+ ],
+ "time": "2024-10-15T16:06:32+00:00"
+ },
+ {
+ "name": "orchestra/canvas",
+ "version": "v9.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/orchestral/canvas.git",
+ "reference": "dbe51d918c4614f9c5ac9b7b7d3baac2360daf5d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/orchestral/canvas/zipball/dbe51d918c4614f9c5ac9b7b7d3baac2360daf5d",
+ "reference": "dbe51d918c4614f9c5ac9b7b7d3baac2360daf5d",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.2",
+ "composer/semver": "^3.0",
+ "illuminate/console": "^11.26",
+ "illuminate/database": "^11.26",
+ "illuminate/filesystem": "^11.26",
+ "illuminate/support": "^11.26",
+ "orchestra/canvas-core": "^9.0",
+ "orchestra/testbench-core": "^9.2",
+ "php": "^8.2",
+ "symfony/polyfill-php83": "^1.28",
+ "symfony/yaml": "^7.0"
+ },
+ "require-dev": {
+ "laravel/framework": "^11.26",
+ "laravel/pint": "^1.17",
+ "mockery/mockery": "^1.6",
+ "phpstan/phpstan": "^1.11",
+ "phpunit/phpunit": "^11.0",
+ "spatie/laravel-ray": "^1.35"
+ },
+ "bin": [
+ "canvas"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "9.0-dev"
+ },
+ "laravel": {
+ "providers": [
+ "Orchestra\\Canvas\\LaravelServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Orchestra\\Canvas\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Mior Muhammad Zaki",
+ "email": "crynobone@gmail.com"
+ }
+ ],
+ "description": "Code Generators for Laravel Applications and Packages",
+ "support": {
+ "issues": "https://github.com/orchestral/canvas/issues",
+ "source": "https://github.com/orchestral/canvas/tree/v9.1.3"
+ },
+ "time": "2024-10-02T01:00:54+00:00"
+ },
+ {
+ "name": "orchestra/canvas-core",
+ "version": "v9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/orchestral/canvas-core.git",
+ "reference": "3a29eecf324fe02e3e5628e422314b5cd1a80e48"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/3a29eecf324fe02e3e5628e422314b5cd1a80e48",
+ "reference": "3a29eecf324fe02e3e5628e422314b5cd1a80e48",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.2",
+ "composer/semver": "^3.0",
+ "illuminate/console": "^11.0",
+ "illuminate/filesystem": "^11.0",
+ "php": "^8.2",
+ "symfony/polyfill-php83": "^1.28"
+ },
+ "require-dev": {
+ "laravel/framework": "^11.0",
+ "laravel/pint": "^1.6",
+ "mockery/mockery": "^1.5.1",
+ "orchestra/testbench-core": "^9.0",
+ "phpstan/phpstan": "^1.10.6",
+ "phpunit/phpunit": "^10.1",
+ "symfony/yaml": "^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "9.0-dev"
+ },
+ "laravel": {
+ "providers": [
+ "Orchestra\\Canvas\\Core\\LaravelServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Orchestra\\Canvas\\Core\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Mior Muhammad Zaki",
+ "email": "crynobone@gmail.com"
+ }
+ ],
+ "description": "Code Generators Builder for Laravel Applications and Packages",
+ "support": {
+ "issues": "https://github.com/orchestral/canvas/issues",
+ "source": "https://github.com/orchestral/canvas-core/tree/v9.0.0"
+ },
+ "time": "2024-03-06T10:00:21+00:00"
+ },
+ {
+ "name": "orchestra/testbench",
+ "version": "v9.5.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/orchestral/testbench.git",
+ "reference": "bc404d840ffbb722bf0bbb042251ef83223482f9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/orchestral/testbench/zipball/bc404d840ffbb722bf0bbb042251ef83223482f9",
+ "reference": "bc404d840ffbb722bf0bbb042251ef83223482f9",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.2",
+ "fakerphp/faker": "^1.23",
+ "laravel/framework": "^11.11",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench-core": "^9.5.3",
+ "orchestra/workbench": "^9.6",
+ "php": "^8.2",
+ "phpunit/phpunit": "^10.5 || ^11.0.1",
+ "symfony/process": "^7.0",
+ "symfony/yaml": "^7.0",
+ "vlucas/phpdotenv": "^5.4.1"
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mior Muhammad Zaki",
+ "email": "crynobone@gmail.com",
+ "homepage": "https://github.com/crynobone"
+ }
+ ],
+ "description": "Laravel Testing Helper for Packages Development",
+ "homepage": "https://packages.tools/testbench/",
+ "keywords": [
+ "BDD",
+ "TDD",
+ "dev",
+ "laravel",
+ "laravel-packages",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/orchestral/testbench/issues",
+ "source": "https://github.com/orchestral/testbench/tree/v9.5.2"
+ },
+ "time": "2024-10-06T13:07:57+00:00"
+ },
+ {
+ "name": "orchestra/testbench-core",
+ "version": "v9.5.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/orchestral/testbench-core.git",
+ "reference": "9a5754622881f601951427a94c04c50e448cbf09"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/9a5754622881f601951427a94c04c50e448cbf09",
+ "reference": "9a5754622881f601951427a94c04c50e448cbf09",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.2",
+ "php": "^8.2",
+ "symfony/polyfill-php83": "^1.28"
+ },
+ "conflict": {
+ "brianium/paratest": "<7.3.0 || >=8.0.0",
+ "laravel/framework": "<11.11.0 || >=12.0.0",
+ "laravel/serializable-closure": "<1.3.0 || >=2.0.0",
+ "nunomaduro/collision": "<8.0.0 || >=9.0.0",
+ "phpunit/phpunit": "<10.5.0 || 11.0.0 || >=11.5.0"
+ },
+ "require-dev": {
+ "fakerphp/faker": "^1.23",
+ "laravel/framework": "^11.11",
+ "laravel/pint": "^1.17",
+ "mockery/mockery": "^1.6",
+ "phpstan/phpstan": "^1.11",
+ "phpunit/phpunit": "^10.5 || ^11.0.1",
+ "spatie/laravel-ray": "^1.35",
+ "symfony/process": "^7.0",
+ "symfony/yaml": "^7.0",
+ "vlucas/phpdotenv": "^5.4.1"
+ },
+ "suggest": {
+ "brianium/paratest": "Allow using parallel tresting (^7.3).",
+ "ext-pcntl": "Required to use all features of the console signal trapping.",
+ "fakerphp/faker": "Allow using Faker for testing (^1.23).",
+ "laravel/framework": "Required for testing (^11.11).",
+ "mockery/mockery": "Allow using Mockery for testing (^1.6).",
+ "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.0).",
+ "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^9.0).",
+ "phpunit/phpunit": "Allow using PHPUnit for testing (^10.5 || ^11.0).",
+ "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.0).",
+ "symfony/yaml": "Required for Testbench CLI (^7.0).",
+ "vlucas/phpdotenv": "Required for Testbench CLI (^5.4.1)."
+ },
+ "bin": [
+ "testbench"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Orchestra\\Testbench\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mior Muhammad Zaki",
+ "email": "crynobone@gmail.com",
+ "homepage": "https://github.com/crynobone"
+ }
+ ],
+ "description": "Testing Helper for Laravel Development",
+ "homepage": "https://packages.tools/testbench",
+ "keywords": [
+ "BDD",
+ "TDD",
+ "dev",
+ "laravel",
+ "laravel-packages",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/orchestral/testbench/issues",
+ "source": "https://github.com/orchestral/testbench-core"
+ },
+ "time": "2024-10-06T11:20:27+00:00"
+ },
+ {
+ "name": "orchestra/workbench",
+ "version": "v9.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/orchestral/workbench.git",
+ "reference": "4bb12d505f24b450d1693e88faddc44a1c835907"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/orchestral/workbench/zipball/4bb12d505f24b450d1693e88faddc44a1c835907",
+ "reference": "4bb12d505f24b450d1693e88faddc44a1c835907",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.2",
+ "fakerphp/faker": "^1.23",
+ "laravel/framework": "^11.11",
+ "laravel/tinker": "^2.9",
+ "nunomaduro/collision": "^8.0",
+ "orchestra/canvas": "^9.1",
+ "orchestra/testbench-core": "^9.4",
+ "php": "^8.1",
+ "spatie/laravel-ray": "^1.35",
+ "symfony/polyfill-php83": "^1.28",
+ "symfony/yaml": "^7.0"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.17",
+ "mockery/mockery": "^1.6",
+ "phpstan/phpstan": "^1.11",
+ "phpunit/phpunit": "^10.5 || ^11.0",
+ "symfony/process": "^7.0"
+ },
+ "suggest": {
+ "ext-pcntl": "Required to use all features of the console signal trapping."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Orchestra\\Workbench\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mior Muhammad Zaki",
+ "email": "crynobone@gmail.com"
+ }
+ ],
+ "description": "Workbench Companion for Laravel Packages Development",
+ "keywords": [
+ "dev",
+ "laravel",
+ "laravel-packages",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/orchestral/workbench/issues",
+ "source": "https://github.com/orchestral/workbench/tree/v9.6.0"
+ },
+ "time": "2024-08-26T05:38:42+00:00"
+ },
+ {
+ "name": "pestphp/pest",
+ "version": "v2.36.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/pestphp/pest.git",
+ "reference": "f8c88bd14dc1772bfaf02169afb601ecdf2724cd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/pestphp/pest/zipball/f8c88bd14dc1772bfaf02169afb601ecdf2724cd",
+ "reference": "f8c88bd14dc1772bfaf02169afb601ecdf2724cd",
+ "shasum": ""
+ },
+ "require": {
+ "brianium/paratest": "^7.3.1",
+ "nunomaduro/collision": "^7.11.0|^8.4.0",
+ "nunomaduro/termwind": "^1.16.0|^2.1.0",
+ "pestphp/pest-plugin": "^2.1.1",
+ "pestphp/pest-plugin-arch": "^2.7.0",
+ "php": "^8.1.0",
+ "phpunit/phpunit": "^10.5.36"
+ },
+ "conflict": {
+ "filp/whoops": "<2.16.0",
+ "phpunit/phpunit": ">10.5.36",
+ "sebastian/exporter": "<5.1.0",
+ "webmozart/assert": "<1.11.0"
+ },
+ "require-dev": {
+ "pestphp/pest-dev-tools": "^2.17.0",
+ "pestphp/pest-plugin-type-coverage": "^2.8.7",
+ "symfony/process": "^6.4.0|^7.1.5"
+ },
+ "bin": [
+ "bin/pest"
+ ],
+ "type": "library",
+ "extra": {
+ "pest": {
+ "plugins": [
+ "Pest\\Plugins\\Bail",
+ "Pest\\Plugins\\Cache",
+ "Pest\\Plugins\\Coverage",
+ "Pest\\Plugins\\Init",
+ "Pest\\Plugins\\Environment",
+ "Pest\\Plugins\\Help",
+ "Pest\\Plugins\\Memory",
+ "Pest\\Plugins\\Only",
+ "Pest\\Plugins\\Printer",
+ "Pest\\Plugins\\ProcessIsolation",
+ "Pest\\Plugins\\Profile",
+ "Pest\\Plugins\\Retry",
+ "Pest\\Plugins\\Snapshot",
+ "Pest\\Plugins\\Verbose",
+ "Pest\\Plugins\\Version",
+ "Pest\\Plugins\\Parallel"
+ ]
+ },
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Functions.php",
+ "src/Pest.php"
+ ],
+ "psr-4": {
+ "Pest\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "The elegant PHP Testing Framework.",
+ "keywords": [
+ "framework",
+ "pest",
+ "php",
+ "test",
+ "testing",
+ "unit"
+ ],
+ "support": {
+ "issues": "https://github.com/pestphp/pest/issues",
+ "source": "https://github.com/pestphp/pest/tree/v2.36.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-15T15:30:56+00:00"
+ },
+ {
+ "name": "pestphp/pest-plugin",
+ "version": "v2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/pestphp/pest-plugin.git",
+ "reference": "e05d2859e08c2567ee38ce8b005d044e72648c0b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/e05d2859e08c2567ee38ce8b005d044e72648c0b",
+ "reference": "e05d2859e08c2567ee38ce8b005d044e72648c0b",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.0.0",
+ "composer-runtime-api": "^2.2.2",
+ "php": "^8.1"
+ },
+ "conflict": {
+ "pestphp/pest": "<2.2.3"
+ },
+ "require-dev": {
+ "composer/composer": "^2.5.8",
+ "pestphp/pest": "^2.16.0",
+ "pestphp/pest-dev-tools": "^2.16.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Pest\\Plugin\\Manager"
+ },
+ "autoload": {
+ "psr-4": {
+ "Pest\\Plugin\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The Pest plugin manager",
+ "keywords": [
+ "framework",
+ "manager",
+ "pest",
+ "php",
+ "plugin",
+ "test",
+ "testing",
+ "unit"
+ ],
+ "support": {
+ "source": "https://github.com/pestphp/pest-plugin/tree/v2.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/nunomaduro",
+ "type": "patreon"
+ }
+ ],
+ "time": "2023-08-22T08:40:06+00:00"
+ },
+ {
+ "name": "pestphp/pest-plugin-arch",
+ "version": "v2.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/pestphp/pest-plugin-arch.git",
+ "reference": "d23b2d7498475354522c3818c42ef355dca3fcda"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/d23b2d7498475354522c3818c42ef355dca3fcda",
+ "reference": "d23b2d7498475354522c3818c42ef355dca3fcda",
+ "shasum": ""
+ },
+ "require": {
+ "nunomaduro/collision": "^7.10.0|^8.1.0",
+ "pestphp/pest-plugin": "^2.1.1",
+ "php": "^8.1",
+ "ta-tikoma/phpunit-architecture-test": "^0.8.4"
+ },
+ "require-dev": {
+ "pestphp/pest": "^2.33.0",
+ "pestphp/pest-dev-tools": "^2.16.0"
+ },
+ "type": "library",
+ "extra": {
+ "pest": {
+ "plugins": [
+ "Pest\\Arch\\Plugin"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Autoload.php"
+ ],
+ "psr-4": {
+ "Pest\\Arch\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The Arch plugin for Pest PHP.",
+ "keywords": [
+ "arch",
+ "architecture",
+ "framework",
+ "pest",
+ "php",
+ "plugin",
+ "test",
+ "testing",
+ "unit"
+ ],
+ "support": {
+ "source": "https://github.com/pestphp/pest-plugin-arch/tree/v2.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ }
+ ],
+ "time": "2024-01-26T09:46:42+00:00"
+ },
+ {
+ "name": "pestphp/pest-plugin-laravel",
+ "version": "v2.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/pestphp/pest-plugin-laravel.git",
+ "reference": "53df51169a7f9595e06839cce638c73e59ace5e8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/53df51169a7f9595e06839cce638c73e59ace5e8",
+ "reference": "53df51169a7f9595e06839cce638c73e59ace5e8",
+ "shasum": ""
+ },
+ "require": {
+ "laravel/framework": "^10.48.9|^11.5.0",
+ "pestphp/pest": "^2.34.7",
+ "php": "^8.1.0"
+ },
+ "require-dev": {
+ "laravel/dusk": "^7.13.0",
+ "orchestra/testbench": "^8.22.3|^9.0.4",
+ "pestphp/pest-dev-tools": "^2.16.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Pest\\Laravel\\PestServiceProvider"
+ ]
+ },
+ "pest": {
+ "plugins": [
+ "Pest\\Laravel\\Plugin"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Autoload.php"
+ ],
+ "psr-4": {
+ "Pest\\Laravel\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The Pest Laravel Plugin",
+ "keywords": [
+ "framework",
+ "laravel",
+ "pest",
+ "php",
+ "test",
+ "testing",
+ "unit"
+ ],
+ "support": {
+ "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-27T10:41:54+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "php-di/invoker",
+ "version": "2.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHP-DI/Invoker.git",
+ "reference": "33234b32dafa8eb69202f950a1fc92055ed76a86"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/33234b32dafa8eb69202f950a1fc92055ed76a86",
+ "reference": "33234b32dafa8eb69202f950a1fc92055ed76a86",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "psr/container": "^1.0|^2.0"
+ },
+ "require-dev": {
+ "athletic/athletic": "~0.1.8",
+ "mnapoli/hard-mode": "~0.3.0",
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Invoker\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Generic and extensible callable invoker",
+ "homepage": "https://github.com/PHP-DI/Invoker",
+ "keywords": [
+ "callable",
+ "dependency",
+ "dependency-injection",
+ "injection",
+ "invoke",
+ "invoker"
+ ],
+ "support": {
+ "issues": "https://github.com/PHP-DI/Invoker/issues",
+ "source": "https://github.com/PHP-DI/Invoker/tree/2.3.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ }
+ ],
+ "time": "2023-09-08T09:24:21+00:00"
+ },
+ {
+ "name": "php-di/php-di",
+ "version": "7.0.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHP-DI/PHP-DI.git",
+ "reference": "e87435e3c0e8f22977adc5af0d5cdcc467e15cf1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/e87435e3c0e8f22977adc5af0d5cdcc467e15cf1",
+ "reference": "e87435e3c0e8f22977adc5af0d5cdcc467e15cf1",
+ "shasum": ""
+ },
+ "require": {
+ "laravel/serializable-closure": "^1.0",
+ "php": ">=8.0",
+ "php-di/invoker": "^2.0",
+ "psr/container": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "psr/container-implementation": "^1.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3",
+ "friendsofphp/proxy-manager-lts": "^1",
+ "mnapoli/phpunit-easymock": "^1.3",
+ "phpunit/phpunit": "^9.5",
+ "vimeo/psalm": "^4.6"
+ },
+ "suggest": {
+ "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "DI\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The dependency injection container for humans",
+ "homepage": "https://php-di.org/",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interop",
+ "dependency injection",
+ "di",
+ "ioc",
+ "psr11"
+ ],
+ "support": {
+ "issues": "https://github.com/PHP-DI/PHP-DI/issues",
+ "source": "https://github.com/PHP-DI/PHP-DI/tree/7.0.7"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-07-21T15:55:45+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-common",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+ },
+ "time": "2020-06-27T09:03:43+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "5.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c",
+ "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.1",
+ "ext-filter": "*",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/type-resolver": "^1.7",
+ "phpstan/phpdoc-parser": "^1.7",
+ "webmozart/assert": "^1.9.1"
+ },
+ "require-dev": {
+ "mockery/mockery": "~1.3.5",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-webmozart-assert": "^1.2",
+ "phpunit/phpunit": "^9.5",
+ "vimeo/psalm": "^5.13"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ },
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.4.1"
+ },
+ "time": "2024-05-21T05:55:05+00:00"
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "1.8.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "153ae662783729388a584b4361f2545e4d841e3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/153ae662783729388a584b4361f2545e4d841e3c",
+ "reference": "153ae662783729388a584b4361f2545e4d841e3c",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.0",
+ "php": "^7.3 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.0",
+ "phpstan/phpdoc-parser": "^1.13"
+ },
+ "require-dev": {
+ "ext-tokenizer": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpunit/phpunit": "^9.5",
+ "rector/rector": "^0.13.9",
+ "vimeo/psalm": "^4.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.8.2"
+ },
+ "time": "2024-02-23T11:10:43+00:00"
+ },
+ {
+ "name": "phpmyadmin/sql-parser",
+ "version": "5.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpmyadmin/sql-parser.git",
+ "reference": "91d980ab76c3f152481e367f62b921adc38af451"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/91d980ab76c3f152481e367f62b921adc38af451",
+ "reference": "91d980ab76c3f152481e367f62b921adc38af451",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0",
+ "symfony/polyfill-mbstring": "^1.3",
+ "symfony/polyfill-php80": "^1.16"
+ },
+ "conflict": {
+ "phpmyadmin/motranslator": "<3.0"
+ },
+ "require-dev": {
+ "phpbench/phpbench": "^1.1",
+ "phpmyadmin/coding-standard": "^3.0",
+ "phpmyadmin/motranslator": "^4.0 || ^5.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.9.12",
+ "phpstan/phpstan-phpunit": "^1.3.3",
+ "phpunit/phpunit": "^8.5 || ^9.6",
+ "psalm/plugin-phpunit": "^0.16.1",
+ "vimeo/psalm": "^4.11",
+ "zumba/json-serializer": "~3.0.2"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance",
+ "phpmyadmin/motranslator": "Translate messages to your favorite locale"
+ },
+ "bin": [
+ "bin/highlight-query",
+ "bin/lint-query",
+ "bin/sql-parser",
+ "bin/tokenize-query"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpMyAdmin\\SqlParser\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "The phpMyAdmin Team",
+ "email": "developers@phpmyadmin.net",
+ "homepage": "https://www.phpmyadmin.net/team/"
+ }
+ ],
+ "description": "A validating SQL lexer and parser with a focus on MySQL dialect.",
+ "homepage": "https://github.com/phpmyadmin/sql-parser",
+ "keywords": [
+ "analysis",
+ "lexer",
+ "parser",
+ "query linter",
+ "sql",
+ "sql lexer",
+ "sql linter",
+ "sql parser",
+ "sql syntax highlighter",
+ "sql tokenizer"
+ ],
+ "support": {
+ "issues": "https://github.com/phpmyadmin/sql-parser/issues",
+ "source": "https://github.com/phpmyadmin/sql-parser"
+ },
+ "funding": [
+ {
+ "url": "https://www.phpmyadmin.net/donate/",
+ "type": "other"
+ }
+ ],
+ "time": "2024-08-29T20:56:34+00:00"
+ },
+ {
+ "name": "phpstan/extension-installer",
+ "version": "1.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/extension-installer.git",
+ "reference": "85e90b3942d06b2326fba0403ec24fe912372936"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936",
+ "reference": "85e90b3942d06b2326fba0403ec24fe912372936",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.0",
+ "php": "^7.2 || ^8.0",
+ "phpstan/phpstan": "^1.9.0 || ^2.0"
+ },
+ "require-dev": {
+ "composer/composer": "^2.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2.0",
+ "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "PHPStan\\ExtensionInstaller\\Plugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\ExtensionInstaller\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Composer plugin for automatic installation of PHPStan extensions",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpstan/extension-installer/issues",
+ "source": "https://github.com/phpstan/extension-installer/tree/1.4.3"
+ },
+ "time": "2024-09-04T20:21:43+00:00"
+ },
+ {
+ "name": "phpstan/phpdoc-parser",
+ "version": "1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpdoc-parser.git",
+ "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140",
+ "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "nikic/php-parser": "^4.15",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^1.5",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpstan/phpstan-strict-rules": "^1.0",
+ "phpunit/phpunit": "^9.5",
+ "symfony/process": "^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\PhpDocParser\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPDoc parser with support for nullable, intersection and generic types",
+ "support": {
+ "issues": "https://github.com/phpstan/phpdoc-parser/issues",
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0"
+ },
+ "time": "2024-10-13T11:25:22+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "1.12.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpstan.git",
+ "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc2b9976bd8b0f84ec9b0e50cc35378551de7af0",
+ "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-18T11:12:07+00:00"
+ },
+ {
+ "name": "phpstan/phpstan-deprecation-rules",
+ "version": "1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpstan-deprecation-rules.git",
+ "reference": "f94d246cc143ec5a23da868f8f7e1393b50eaa82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/f94d246cc143ec5a23da868f8f7e1393b50eaa82",
+ "reference": "f94d246cc143ec5a23da868f8f7e1393b50eaa82",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0",
+ "phpstan/phpstan": "^1.12"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/phpstan-phpunit": "^1.0",
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "rules.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.",
+ "support": {
+ "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues",
+ "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.2.1"
+ },
+ "time": "2024-09-11T15:52:35+00:00"
+ },
+ {
+ "name": "phpstan/phpstan-phpunit",
+ "version": "1.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpstan-phpunit.git",
+ "reference": "f3ea021866f4263f07ca3636bf22c64be9610c11"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f3ea021866f4263f07ca3636bf22c64be9610c11",
+ "reference": "f3ea021866f4263f07ca3636bf22c64be9610c11",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0",
+ "phpstan/phpstan": "^1.11"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<7.0"
+ },
+ "require-dev": {
+ "nikic/php-parser": "^4.13.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/phpstan-strict-rules": "^1.5.1",
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon",
+ "rules.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPUnit extensions and rules for PHPStan",
+ "support": {
+ "issues": "https://github.com/phpstan/phpstan-phpunit/issues",
+ "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.4.0"
+ },
+ "time": "2024-04-20T06:39:00+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "10.1.16",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "7e308268858ed6baedc8704a304727d20bc07c77"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77",
+ "reference": "7e308268858ed6baedc8704a304727d20bc07c77",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^4.19.1 || ^5.1.0",
+ "php": ">=8.1",
+ "phpunit/php-file-iterator": "^4.1.0",
+ "phpunit/php-text-template": "^3.0.1",
+ "sebastian/code-unit-reverse-lookup": "^3.0.0",
+ "sebastian/complexity": "^3.2.0",
+ "sebastian/environment": "^6.1.0",
+ "sebastian/lines-of-code": "^2.0.2",
+ "sebastian/version": "^4.0.1",
+ "theseer/tokenizer": "^1.2.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.1"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "10.1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-22T04:31:57+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "4.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c",
+ "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-08-31T06:24:48+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+ "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^10.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:56:09+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+ "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-08-31T14:07:24+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+ "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:57:52+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "10.5.36",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "aa0a8ce701ea7ee314b0dfaa8970dc94f3f8c870"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aa0a8ce701ea7ee314b0dfaa8970dc94f3f8c870",
+ "reference": "aa0a8ce701ea7ee314b0dfaa8970dc94f3f8c870",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.12.0",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.1",
+ "phpunit/php-code-coverage": "^10.1.16",
+ "phpunit/php-file-iterator": "^4.1.0",
+ "phpunit/php-invoker": "^4.0.0",
+ "phpunit/php-text-template": "^3.0.1",
+ "phpunit/php-timer": "^6.0.0",
+ "sebastian/cli-parser": "^2.0.1",
+ "sebastian/code-unit": "^2.0.0",
+ "sebastian/comparator": "^5.0.2",
+ "sebastian/diff": "^5.1.1",
+ "sebastian/environment": "^6.1.0",
+ "sebastian/exporter": "^5.1.2",
+ "sebastian/global-state": "^6.0.2",
+ "sebastian/object-enumerator": "^5.0.0",
+ "sebastian/recursion-context": "^5.0.0",
+ "sebastian/type": "^4.0.0",
+ "sebastian/version": "^4.0.1"
+ },
+ "suggest": {
+ "ext-soap": "To be able to generate mocks based on WSDL files"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "10.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.36"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-10-08T15:36:51+00:00"
+ },
+ {
+ "name": "psy/psysh",
+ "version": "v0.12.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/bobthecow/psysh.git",
+ "reference": "2fd717afa05341b4f8152547f142cd2f130f6818"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/bobthecow/psysh/zipball/2fd717afa05341b4f8152547f142cd2f130f6818",
+ "reference": "2fd717afa05341b4f8152547f142cd2f130f6818",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "nikic/php-parser": "^5.0 || ^4.0",
+ "php": "^8.0 || ^7.4",
+ "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4",
+ "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4"
+ },
+ "conflict": {
+ "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.2"
+ },
+ "suggest": {
+ "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)",
+ "ext-pdo-sqlite": "The doc command requires SQLite to work.",
+ "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well."
+ },
+ "bin": [
+ "bin/psysh"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "0.12.x-dev"
+ },
+ "bamarni-bin": {
+ "bin-links": false,
+ "forward-command": false
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Psy\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Justin Hileman",
+ "email": "justin@justinhileman.info",
+ "homepage": "http://justinhileman.com"
+ }
+ ],
+ "description": "An interactive shell for modern PHP.",
+ "homepage": "http://psysh.org",
+ "keywords": [
+ "REPL",
+ "console",
+ "interactive",
+ "shell"
+ ],
+ "support": {
+ "issues": "https://github.com/bobthecow/psysh/issues",
+ "source": "https://github.com/bobthecow/psysh/tree/v0.12.4"
+ },
+ "time": "2024-06-10T01:18:23+00:00"
+ },
+ {
+ "name": "rector/rector",
+ "version": "1.2.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/rectorphp/rector.git",
+ "reference": "05755bf43617449c08ee8e50fb840c85ad3b1240"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/05755bf43617449c08ee8e50fb840c85ad3b1240",
+ "reference": "05755bf43617449c08ee8e50fb840c85ad3b1240",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2|^8.0",
+ "phpstan/phpstan": "^1.12.5"
+ },
+ "conflict": {
+ "rector/rector-doctrine": "*",
+ "rector/rector-downgrade-php": "*",
+ "rector/rector-phpunit": "*",
+ "rector/rector-symfony": "*"
+ },
+ "suggest": {
+ "ext-dom": "To manipulate phpunit.xml via the custom-rule command"
+ },
+ "bin": [
+ "bin/rector"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Instant Upgrade and Automated Refactoring of any PHP code",
+ "keywords": [
+ "automation",
+ "dev",
+ "migration",
+ "refactoring"
+ ],
+ "support": {
+ "issues": "https://github.com/rectorphp/rector/issues",
+ "source": "https://github.com/rectorphp/rector/tree/1.2.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/tomasvotruba",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-18T11:54:27+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+ "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:12:49+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "a81fee9eef0b7a76af11d121767abc44c104e503"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503",
+ "reference": "a81fee9eef0b7a76af11d121767abc44c104e503",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:58:43+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+ "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:59:15+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "5.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e",
+ "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "sebastian/diff": "^5.0",
+ "sebastian/exporter": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-18T14:56:07+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "3.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "68ff824baeae169ec9f2137158ee529584553799"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799",
+ "reference": "68ff824baeae169ec9f2137158ee529584553799",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-21T08:37:17+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "5.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e",
+ "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0",
+ "symfony/process": "^6.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:15:17+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "6.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "8074dbcd93529b357029f5cc5058fd3e43666984"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984",
+ "reference": "8074dbcd93529b357029f5cc5058fd3e43666984",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-23T08:47:14+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "5.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "955288482d97c19a372d3f31006ab3f37da47adf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf",
+ "reference": "955288482d97c19a372d3f31006ab3f37da47adf",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:17:12+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "6.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+ "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "sebastian/object-reflector": "^3.0",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:19:19+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0",
+ "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-21T08:38:20+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906",
+ "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "sebastian/object-reflector": "^3.0",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:08:32+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "24ed13d98130f0e7122df55d06c5c4942a577957"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957",
+ "reference": "24ed13d98130f0e7122df55d06c5c4942a577957",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:06:18+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "05909fb5bc7df4c52992396d0116aed689f93712"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712",
+ "reference": "05909fb5bc7df4c52992396d0116aed689f93712",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:05:40+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "462699a16464c3944eefc02ebdd77882bd3925bf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf",
+ "reference": "462699a16464c3944eefc02ebdd77882bd3925bf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/4.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:10:45+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+ "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-07T11:34:05+00:00"
+ },
+ {
+ "name": "spatie/backtrace",
+ "version": "1.6.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/backtrace.git",
+ "reference": "1a9a145b044677ae3424693f7b06479fc8c137a9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/backtrace/zipball/1a9a145b044677ae3424693f7b06479fc8c137a9",
+ "reference": "1a9a145b044677ae3424693f7b06479fc8c137a9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.3|^8.0"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "laravel/serializable-closure": "^1.3",
+ "phpunit/phpunit": "^9.3",
+ "spatie/phpunit-snapshot-assertions": "^4.2",
+ "symfony/var-dumper": "^5.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\Backtrace\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van de Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "A better backtrace",
+ "homepage": "https://github.com/spatie/backtrace",
+ "keywords": [
+ "Backtrace",
+ "spatie"
+ ],
+ "support": {
+ "source": "https://github.com/spatie/backtrace/tree/1.6.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/spatie",
+ "type": "github"
+ },
+ {
+ "url": "https://spatie.be/open-source/support-us",
+ "type": "other"
+ }
+ ],
+ "time": "2024-07-22T08:21:24+00:00"
+ },
+ {
+ "name": "spatie/laravel-ray",
+ "version": "1.37.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-ray.git",
+ "reference": "c2bedfd1172648df2c80aaceb2541d70f1d9a5b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/c2bedfd1172648df2c80aaceb2541d70f1d9a5b9",
+ "reference": "c2bedfd1172648df2c80aaceb2541d70f1d9a5b9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0",
+ "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0",
+ "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0",
+ "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0",
+ "php": "^7.4|^8.0",
+ "rector/rector": "^0.19.2|^1.0",
+ "spatie/backtrace": "^1.0",
+ "spatie/ray": "^1.41.1",
+ "symfony/stopwatch": "4.2|^5.1|^6.0|^7.0",
+ "zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0"
+ },
+ "require-dev": {
+ "guzzlehttp/guzzle": "^7.3",
+ "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0",
+ "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0",
+ "pestphp/pest": "^1.22|^2.0",
+ "phpstan/phpstan": "^1.10.57",
+ "phpunit/phpunit": "^9.3|^10.1",
+ "spatie/pest-plugin-snapshots": "^1.1|^2.0",
+ "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ },
+ "laravel": {
+ "providers": [
+ "Spatie\\LaravelRay\\RayServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Spatie\\LaravelRay\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Easily debug Laravel apps",
+ "homepage": "https://github.com/spatie/laravel-ray",
+ "keywords": [
+ "laravel-ray",
+ "spatie"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/laravel-ray/issues",
+ "source": "https://github.com/spatie/laravel-ray/tree/1.37.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/spatie",
+ "type": "github"
+ },
+ {
+ "url": "https://spatie.be/open-source/support-us",
+ "type": "other"
+ }
+ ],
+ "time": "2024-07-12T12:35:17+00:00"
+ },
+ {
+ "name": "spatie/macroable",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/macroable.git",
+ "reference": "ec2c320f932e730607aff8052c44183cf3ecb072"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/macroable/zipball/ec2c320f932e730607aff8052c44183cf3ecb072",
+ "reference": "ec2c320f932e730607aff8052c44183cf3ecb072",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.0|^9.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\Macroable\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "A trait to dynamically add methods to a class",
+ "homepage": "https://github.com/spatie/macroable",
+ "keywords": [
+ "macroable",
+ "spatie"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/macroable/issues",
+ "source": "https://github.com/spatie/macroable/tree/2.0.0"
+ },
+ "time": "2021-03-26T22:39:02+00:00"
+ },
+ {
+ "name": "spatie/ray",
+ "version": "1.41.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/ray.git",
+ "reference": "c44f8cfbf82c69909b505de61d8d3f2d324e93fc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/ray/zipball/c44f8cfbf82c69909b505de61d8d3f2d324e93fc",
+ "reference": "c44f8cfbf82c69909b505de61d8d3f2d324e93fc",
+ "shasum": ""
+ },
+ "require": {
+ "ext-curl": "*",
+ "ext-json": "*",
+ "php": "^7.3|^8.0",
+ "ramsey/uuid": "^3.0|^4.1",
+ "spatie/backtrace": "^1.1",
+ "spatie/macroable": "^1.0|^2.0",
+ "symfony/stopwatch": "^4.0|^5.1|^6.0|^7.0",
+ "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3"
+ },
+ "require-dev": {
+ "illuminate/support": "6.x|^8.18|^9.0",
+ "nesbot/carbon": "^2.63",
+ "pestphp/pest": "^1.22",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^9.5",
+ "rector/rector": "^0.19.2",
+ "spatie/phpunit-snapshot-assertions": "^4.2",
+ "spatie/test-time": "^1.2"
+ },
+ "bin": [
+ "bin/remove-ray.sh"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/helpers.php"
+ ],
+ "psr-4": {
+ "Spatie\\Ray\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Debug with Ray to fix problems faster",
+ "homepage": "https://github.com/spatie/ray",
+ "keywords": [
+ "ray",
+ "spatie"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/ray/issues",
+ "source": "https://github.com/spatie/ray/tree/1.41.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/spatie",
+ "type": "github"
+ },
+ {
+ "url": "https://spatie.be/open-source/support-us",
+ "type": "other"
+ }
+ ],
+ "time": "2024-04-24T14:21:46+00:00"
+ },
+ {
+ "name": "symfony/polyfill-iconv",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-iconv.git",
+ "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/48becf00c920479ca2e910c22a5a39e5d47ca956",
+ "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-iconv": "*"
+ },
+ "suggest": {
+ "ext-iconv": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Iconv\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Iconv extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "iconv",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-iconv/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/stopwatch",
+ "version": "v7.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/stopwatch.git",
+ "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d",
+ "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Stopwatch\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides a way to profile code",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/stopwatch/tree/v7.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-31T14:57:53+00:00"
+ },
+ {
+ "name": "symfony/yaml",
+ "version": "v7.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/yaml.git",
+ "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/4e561c316e135e053bd758bf3b3eb291d9919de4",
+ "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "conflict": {
+ "symfony/console": "<6.4"
+ },
+ "require-dev": {
+ "symfony/console": "^6.4|^7.0"
+ },
+ "bin": [
+ "Resources/bin/yaml-lint"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Yaml\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Loads and dumps YAML files",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v7.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-17T12:49:58+00:00"
+ },
+ {
+ "name": "ta-tikoma/phpunit-architecture-test",
+ "version": "0.8.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git",
+ "reference": "89f0dea1cb0f0d5744d3ec1764a286af5e006636"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/89f0dea1cb0f0d5744d3ec1764a286af5e006636",
+ "reference": "89f0dea1cb0f0d5744d3ec1764a286af5e006636",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18.0 || ^5.0.0",
+ "php": "^8.1.0",
+ "phpdocumentor/reflection-docblock": "^5.3.0",
+ "phpunit/phpunit": "^10.5.5 || ^11.0.0",
+ "symfony/finder": "^6.4.0 || ^7.0.0"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.13.7",
+ "phpstan/phpstan": "^1.10.52"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPUnit\\Architecture\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ni Shi",
+ "email": "futik0ma011@gmail.com"
+ },
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "Methods for testing application architecture",
+ "keywords": [
+ "architecture",
+ "phpunit",
+ "stucture",
+ "test",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues",
+ "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.4"
+ },
+ "time": "2024-01-05T14:10:56+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+ "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.2.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:36:25+00:00"
+ },
+ {
+ "name": "zbateson/mail-mime-parser",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/zbateson/mail-mime-parser.git",
+ "reference": "e0d4423fe27850c9dd301190767dbc421acc2f19"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/e0d4423fe27850c9dd301190767dbc421acc2f19",
+ "reference": "e0d4423fe27850c9dd301190767dbc421acc2f19",
+ "shasum": ""
+ },
+ "require": {
+ "guzzlehttp/psr7": "^2.5",
+ "php": ">=8.0",
+ "php-di/php-di": "^6.0|^7.0",
+ "psr/log": "^1|^2|^3",
+ "zbateson/mb-wrapper": "^2.0",
+ "zbateson/stream-decorators": "^2.1"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "*",
+ "monolog/monolog": "^2|^3",
+ "phpstan/phpstan": "*",
+ "phpunit/phpunit": "^9.6"
+ },
+ "suggest": {
+ "ext-iconv": "For best support/performance",
+ "ext-mbstring": "For best support/performance"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ZBateson\\MailMimeParser\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Zaahid Bateson"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/zbateson/mail-mime-parser/graphs/contributors"
+ }
+ ],
+ "description": "MIME email message parser",
+ "homepage": "https://mail-mime-parser.org",
+ "keywords": [
+ "MimeMailParser",
+ "email",
+ "mail",
+ "mailparse",
+ "mime",
+ "mimeparse",
+ "parser",
+ "php-imap"
+ ],
+ "support": {
+ "docs": "https://mail-mime-parser.org/#usage-guide",
+ "issues": "https://github.com/zbateson/mail-mime-parser/issues",
+ "source": "https://github.com/zbateson/mail-mime-parser"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/zbateson",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-10T18:44:09+00:00"
+ },
+ {
+ "name": "zbateson/mb-wrapper",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/zbateson/mb-wrapper.git",
+ "reference": "9e4373a153585d12b6c621ac4a6bb143264d4619"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/9e4373a153585d12b6c621ac4a6bb143264d4619",
+ "reference": "9e4373a153585d12b6c621ac4a6bb143264d4619",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0",
+ "symfony/polyfill-iconv": "^1.9",
+ "symfony/polyfill-mbstring": "^1.9"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "*",
+ "phpstan/phpstan": "*",
+ "phpunit/phpunit": "<10.0"
+ },
+ "suggest": {
+ "ext-iconv": "For best support/performance",
+ "ext-mbstring": "For best support/performance"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ZBateson\\MbWrapper\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Zaahid Bateson"
+ }
+ ],
+ "description": "Wrapper for mbstring with fallback to iconv for encoding conversion and string manipulation",
+ "keywords": [
+ "charset",
+ "encoding",
+ "http",
+ "iconv",
+ "mail",
+ "mb",
+ "mb_convert_encoding",
+ "mbstring",
+ "mime",
+ "multibyte",
+ "string"
+ ],
+ "support": {
+ "issues": "https://github.com/zbateson/mb-wrapper/issues",
+ "source": "https://github.com/zbateson/mb-wrapper/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/zbateson",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-20T01:38:07+00:00"
+ },
+ {
+ "name": "zbateson/stream-decorators",
+ "version": "2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/zbateson/stream-decorators.git",
+ "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/32a2a62fb0f26313395c996ebd658d33c3f9c4e5",
+ "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5",
+ "shasum": ""
+ },
+ "require": {
+ "guzzlehttp/psr7": "^2.5",
+ "php": ">=8.0",
+ "zbateson/mb-wrapper": "^2.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "*",
+ "phpstan/phpstan": "*",
+ "phpunit/phpunit": "^9.6|^10.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ZBateson\\StreamDecorators\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Zaahid Bateson"
+ }
+ ],
+ "description": "PHP psr7 stream decorators for mime message part streams",
+ "keywords": [
+ "base64",
+ "charset",
+ "decorators",
+ "mail",
+ "mime",
+ "psr7",
+ "quoted-printable",
+ "stream",
+ "uuencode"
+ ],
+ "support": {
+ "issues": "https://github.com/zbateson/stream-decorators/issues",
+ "source": "https://github.com/zbateson/stream-decorators/tree/2.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/zbateson",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-29T21:42:39+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "dev",
+ "stability-flags": [],
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^8.1"
+ },
+ "platform-dev": [],
+ "plugin-api-version": "2.6.0"
+}
diff --git a/config/transl.php b/config/transl.php
new file mode 100644
index 0000000..9513e67
--- /dev/null
+++ b/config/transl.php
@@ -0,0 +1,139 @@
+ [
+ /**
+ * Whether translation keys used but for which
+ * no corresponding translation value could be
+ * found should be reported to Transl.
+ *
+ * Ex.: `__('nonexistent')` -> reports "nonexistent".
+ *
+ * By default, enabled only on `app()->isProduction()`.
+ */
+ 'should_report_missing_translation_keys' => null,
+
+ /**
+ * The class that should be used to report missing translation
+ * keys. The class should have either an `__invokable` or
+ * `execute` method.
+ */
+ 'report_missing_translation_keys_using' => ReportMissingTranslationKeysAction::class,
+
+ /**
+ * Whether exceptions thrown during the catching and reporting
+ * process should be silently discarded. Probably best to
+ * enable this in production environnements.
+ *
+ * By default, enabled only on `app()->isProduction()`.
+ */
+ 'silently_discard_exceptions' => null,
+ ],
+
+ 'defaults' => [
+ /**
+ * Default project options that will be used in filling
+ * a given project's option that hasn't been given a value.
+ * In other words, fallback option values for a given project.
+ *
+ * The exact same as `project.options`.
+ *
+ * @see `\Transl\Config\Values\ProjectConfigurationOptions`
+ */
+ 'project_options' => [
+ /**
+ * A local directory used to store/cache/track
+ * necessary informations.
+ *
+ * - If set to `null`, `storage_path('app/.transl')` will be used.
+ * - If set to `false`, the feature will be disabled (conflicts won't be detected).
+ */
+ 'transl_directory' => storage_path('app/.transl'),
+
+ /**
+ * The project's branching specific configurations.
+ */
+ 'branching' => [
+ /**
+ * The default branch name to use in contexts where
+ * none was provided and/or none could be determined
+ * either because of limitations or configurations.
+ */
+ 'default_branch_name' => 'main',
+
+ /**
+ * Whether local Git branches, when pushing translation
+ * lines to Transl, should be reflected on Transl.
+ */
+ 'mirror_current_branch' => true,
+
+ /**
+ * How detected conflicts should be handled.
+ * Check the Enum for more details and values.
+ */
+ 'conflict_resolution' => BranchingConflictResolutionEnum::MERGE_BUT_THROW->value,
+ ],
+ ],
+ ],
+
+ 'projects' => [
+ [
+ /**
+ * The project's authentication key.
+ * Used to both identify the project and
+ * the user making local and remote changes.
+ *
+ * This value should be unique per team members/bots.
+ * This value should be created/retrieved/refreshed from Transl.
+ */
+ 'auth_key' => env('TRANSL_KEY'),
+
+ /**
+ * A user friendly name given to the project.
+ * Used when printing the project back to the user
+ * in console outputs, exception messages etc... .
+ *
+ * Falls back to be a truncated and redacted version
+ * of the authentication key.
+ */
+ 'name' => 'My first project',
+
+ /**
+ * The project's configuration options.
+ * Used to configure behaviors.
+ *
+ * Same as and merged with (overwriting) "project_options"
+ * in the above "defaults" key.
+ *
+ * @see `\Transl\Config\Values\ProjectConfigurationOptions`
+ */
+ 'options' => [],
+
+ /**
+ * The project's configuration drivers.
+ * Used for identifying, retrieving, updating and handling
+ * translation contents.
+ */
+ 'drivers' => [
+ /**
+ * A driver scanning local directories for translation files
+ * based on Laravel's default behavior.
+ *
+ * Check out the classe's constructor properties for a full
+ * list of possible params and their description.
+ */
+ LocalFilesDriver::class => [
+ 'language_directories' => [
+ lang_path(),
+ ],
+ ],
+ ],
+ ],
+ ],
+];
diff --git a/lint-staged.config.js b/lint-staged.config.js
new file mode 100644
index 0000000..c8715f2
--- /dev/null
+++ b/lint-staged.config.js
@@ -0,0 +1,5 @@
+export default {
+ '*.{js,ts,vue}': ['eslint . --max-warnings=0', 'prettier -l'],
+ '*.json': 'eslint . --max-warnings=0',
+ '*.{json,html,yml,md,css,php}': 'prettier . -l',
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..31fb1b9
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1234 @@
+{
+ "name": "laravel-transl",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "hasInstallScript": true,
+ "license": "MIT",
+ "devDependencies": {
+ "@commitlint/cli": "^19.5.0",
+ "@commitlint/config-conventional": "^19.5.0",
+ "husky": "^9.1.6"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.9.tgz",
+ "integrity": "sha512-z88xeGxnzehn2sqZ8UdGQEvYErF1odv2CftxInpSYJt6uHuPe9YjahKZITGs3l5LeI9d2ROG+obuDAoSlqbNfQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/highlight": "^7.25.9",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
+ "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/highlight": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz",
+ "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "chalk": "^2.4.2",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "node_modules/@commitlint/cli": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.5.0.tgz",
+ "integrity": "sha512-gaGqSliGwB86MDmAAKAtV9SV1SHdmN8pnGq4EJU4+hLisQ7IFfx4jvU4s+pk6tl0+9bv6yT+CaZkufOinkSJIQ==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/format": "^19.5.0",
+ "@commitlint/lint": "^19.5.0",
+ "@commitlint/load": "^19.5.0",
+ "@commitlint/read": "^19.5.0",
+ "@commitlint/types": "^19.5.0",
+ "tinyexec": "^0.3.0",
+ "yargs": "^17.0.0"
+ },
+ "bin": {
+ "commitlint": "cli.js"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/config-conventional": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.5.0.tgz",
+ "integrity": "sha512-OBhdtJyHNPryZKg0fFpZNOBM1ZDbntMvqMuSmpfyP86XSfwzGw4CaoYRG4RutUPg0BTK07VMRIkNJT6wi2zthg==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/types": "^19.5.0",
+ "conventional-changelog-conventionalcommits": "^7.0.2"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/config-validator": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.5.0.tgz",
+ "integrity": "sha512-CHtj92H5rdhKt17RmgALhfQt95VayrUo2tSqY9g2w+laAXyk7K/Ef6uPm9tn5qSIwSmrLjKaXK9eiNuxmQrDBw==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/types": "^19.5.0",
+ "ajv": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/ensure": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.5.0.tgz",
+ "integrity": "sha512-Kv0pYZeMrdg48bHFEU5KKcccRfKmISSm9MvgIgkpI6m+ohFTB55qZlBW6eYqh/XDfRuIO0x4zSmvBjmOwWTwkg==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/types": "^19.5.0",
+ "lodash.camelcase": "^4.3.0",
+ "lodash.kebabcase": "^4.1.1",
+ "lodash.snakecase": "^4.1.1",
+ "lodash.startcase": "^4.4.0",
+ "lodash.upperfirst": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/execute-rule": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.5.0.tgz",
+ "integrity": "sha512-aqyGgytXhl2ejlk+/rfgtwpPexYyri4t8/n4ku6rRJoRhGZpLFMqrZ+YaubeGysCP6oz4mMA34YSTaSOKEeNrg==",
+ "dev": true,
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/format": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.5.0.tgz",
+ "integrity": "sha512-yNy088miE52stCI3dhG/vvxFo9e4jFkU1Mj3xECfzp/bIS/JUay4491huAlVcffOoMK1cd296q0W92NlER6r3A==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/types": "^19.5.0",
+ "chalk": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/is-ignored": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.5.0.tgz",
+ "integrity": "sha512-0XQ7Llsf9iL/ANtwyZ6G0NGp5Y3EQ8eDQSxv/SRcfJ0awlBY4tHFAvwWbw66FVUaWICH7iE5en+FD9TQsokZ5w==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/types": "^19.5.0",
+ "semver": "^7.6.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/lint": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.5.0.tgz",
+ "integrity": "sha512-cAAQwJcRtiBxQWO0eprrAbOurtJz8U6MgYqLz+p9kLElirzSCc0vGMcyCaA1O7AqBuxo11l1XsY3FhOFowLAAg==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/is-ignored": "^19.5.0",
+ "@commitlint/parse": "^19.5.0",
+ "@commitlint/rules": "^19.5.0",
+ "@commitlint/types": "^19.5.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/load": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.5.0.tgz",
+ "integrity": "sha512-INOUhkL/qaKqwcTUvCE8iIUf5XHsEPCLY9looJ/ipzi7jtGhgmtH7OOFiNvwYgH7mA8osUWOUDV8t4E2HAi4xA==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/config-validator": "^19.5.0",
+ "@commitlint/execute-rule": "^19.5.0",
+ "@commitlint/resolve-extends": "^19.5.0",
+ "@commitlint/types": "^19.5.0",
+ "chalk": "^5.3.0",
+ "cosmiconfig": "^9.0.0",
+ "cosmiconfig-typescript-loader": "^5.0.0",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.merge": "^4.6.2",
+ "lodash.uniq": "^4.5.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/message": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.5.0.tgz",
+ "integrity": "sha512-R7AM4YnbxN1Joj1tMfCyBryOC5aNJBdxadTZkuqtWi3Xj0kMdutq16XQwuoGbIzL2Pk62TALV1fZDCv36+JhTQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/parse": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.5.0.tgz",
+ "integrity": "sha512-cZ/IxfAlfWYhAQV0TwcbdR1Oc0/r0Ik1GEessDJ3Lbuma/MRO8FRQX76eurcXtmhJC//rj52ZSZuXUg0oIX0Fw==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/types": "^19.5.0",
+ "conventional-changelog-angular": "^7.0.0",
+ "conventional-commits-parser": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/read": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.5.0.tgz",
+ "integrity": "sha512-TjS3HLPsLsxFPQj6jou8/CZFAmOP2y+6V4PGYt3ihbQKTY1Jnv0QG28WRKl/d1ha6zLODPZqsxLEov52dhR9BQ==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/top-level": "^19.5.0",
+ "@commitlint/types": "^19.5.0",
+ "git-raw-commits": "^4.0.0",
+ "minimist": "^1.2.8",
+ "tinyexec": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/resolve-extends": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.5.0.tgz",
+ "integrity": "sha512-CU/GscZhCUsJwcKTJS9Ndh3AKGZTNFIOoQB2n8CmFnizE0VnEuJoum+COW+C1lNABEeqk6ssfc1Kkalm4bDklA==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/config-validator": "^19.5.0",
+ "@commitlint/types": "^19.5.0",
+ "global-directory": "^4.0.1",
+ "import-meta-resolve": "^4.0.0",
+ "lodash.mergewith": "^4.6.2",
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/rules": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.5.0.tgz",
+ "integrity": "sha512-hDW5TPyf/h1/EufSHEKSp6Hs+YVsDMHazfJ2azIk9tHPXS6UqSz1dIRs1gpqS3eMXgtkT7JH6TW4IShdqOwhAw==",
+ "dev": true,
+ "dependencies": {
+ "@commitlint/ensure": "^19.5.0",
+ "@commitlint/message": "^19.5.0",
+ "@commitlint/to-lines": "^19.5.0",
+ "@commitlint/types": "^19.5.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/to-lines": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.5.0.tgz",
+ "integrity": "sha512-R772oj3NHPkodOSRZ9bBVNq224DOxQtNef5Pl8l2M8ZnkkzQfeSTr4uxawV2Sd3ui05dUVzvLNnzenDBO1KBeQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/top-level": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.5.0.tgz",
+ "integrity": "sha512-IP1YLmGAk0yWrImPRRc578I3dDUI5A2UBJx9FbSOjxe9sTlzFiwVJ+zeMLgAtHMtGZsC8LUnzmW1qRemkFU4ng==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@commitlint/types": {
+ "version": "19.5.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.5.0.tgz",
+ "integrity": "sha512-DSHae2obMSMkAtTBSOulg5X7/z+rGLxcXQIkg3OmWvY6wifojge5uVMydfhUvs7yQj+V7jNmRZ2Xzl8GJyqRgg==",
+ "dev": true,
+ "dependencies": {
+ "@types/conventional-commits-parser": "^5.0.0",
+ "chalk": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=v18"
+ }
+ },
+ "node_modules/@types/conventional-commits-parser": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz",
+ "integrity": "sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.7.9",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz",
+ "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~6.19.2"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "node_modules/array-ify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz",
+ "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==",
+ "dev": true
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+ "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+ "dev": true,
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/compare-func": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz",
+ "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==",
+ "dev": true,
+ "dependencies": {
+ "array-ify": "^1.0.0",
+ "dot-prop": "^5.1.0"
+ }
+ },
+ "node_modules/conventional-changelog-angular": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz",
+ "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==",
+ "dev": true,
+ "dependencies": {
+ "compare-func": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/conventional-changelog-conventionalcommits": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz",
+ "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==",
+ "dev": true,
+ "dependencies": {
+ "compare-func": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/conventional-commits-parser": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz",
+ "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==",
+ "dev": true,
+ "dependencies": {
+ "is-text-path": "^2.0.0",
+ "JSONStream": "^1.3.5",
+ "meow": "^12.0.1",
+ "split2": "^4.0.0"
+ },
+ "bin": {
+ "conventional-commits-parser": "cli.mjs"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/cosmiconfig": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
+ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
+ "dev": true,
+ "dependencies": {
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cosmiconfig-typescript-loader": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.1.0.tgz",
+ "integrity": "sha512-7PtBB+6FdsOvZyJtlF3hEPpACq7RQX6BVGsgC7/lfVXnKMvNCu/XY3ykreqG5w/rBNdu2z8LCIKoF3kpHHdHlA==",
+ "dev": true,
+ "dependencies": {
+ "jiti": "^1.21.6"
+ },
+ "engines": {
+ "node": ">=v16"
+ },
+ "peerDependencies": {
+ "@types/node": "*",
+ "cosmiconfig": ">=8.2",
+ "typescript": ">=4"
+ }
+ },
+ "node_modules/dargs": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz",
+ "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/dot-prop": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
+ "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
+ "dev": true,
+ "dependencies": {
+ "is-obj": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-uri": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz",
+ "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==",
+ "dev": true
+ },
+ "node_modules/find-up": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz",
+ "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^7.2.0",
+ "path-exists": "^5.0.0",
+ "unicorn-magic": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/git-raw-commits": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz",
+ "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==",
+ "dev": true,
+ "dependencies": {
+ "dargs": "^8.0.0",
+ "meow": "^12.0.1",
+ "split2": "^4.0.0"
+ },
+ "bin": {
+ "git-raw-commits": "cli.mjs"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/global-directory": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz",
+ "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==",
+ "dev": true,
+ "dependencies": {
+ "ini": "4.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/husky": {
+ "version": "9.1.6",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz",
+ "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==",
+ "dev": true,
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-fresh/node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/import-meta-resolve": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
+ "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ini": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz",
+ "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-obj": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
+ "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-text-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz",
+ "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==",
+ "dev": true,
+ "dependencies": {
+ "text-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.6",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz",
+ "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==",
+ "dev": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true
+ },
+ "node_modules/jsonparse": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
+ "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
+ "dev": true,
+ "engines": [
+ "node >= 0.2.0"
+ ]
+ },
+ "node_modules/JSONStream": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz",
+ "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==",
+ "dev": true,
+ "dependencies": {
+ "jsonparse": "^1.2.0",
+ "through": ">=2.2.7 <3"
+ },
+ "bin": {
+ "JSONStream": "bin.js"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
+ },
+ "node_modules/locate-path": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz",
+ "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^6.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+ "dev": true
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "dev": true
+ },
+ "node_modules/lodash.kebabcase": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
+ "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==",
+ "dev": true
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "node_modules/lodash.mergewith": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
+ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
+ "dev": true
+ },
+ "node_modules/lodash.snakecase": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
+ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
+ "dev": true
+ },
+ "node_modules/lodash.startcase": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz",
+ "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==",
+ "dev": true
+ },
+ "node_modules/lodash.uniq": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+ "dev": true
+ },
+ "node_modules/lodash.upperfirst": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz",
+ "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==",
+ "dev": true
+ },
+ "node_modules/meow": {
+ "version": "12.1.1",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz",
+ "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
+ "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz",
+ "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
+ "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/text-extensions": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz",
+ "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/through": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
+ "dev": true
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz",
+ "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==",
+ "dev": true
+ },
+ "node_modules/typescript": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+ "dev": true,
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.19.8",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+ "dev": true
+ },
+ "node_modules/unicorn-magic": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
+ "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
+ "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9ee2896
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "license": "SEE LICENSE IN LICENSE",
+ "private": true,
+ "scripts": {
+ "------------------------------------- | AUTO | -------------------------------------": "",
+ "postinstall": "npm run init",
+ "------------------------------------- | INIT | -------------------------------------": "",
+ "init": "npm run init:husky",
+ "init:husky": "husky install"
+ },
+ "devDependencies": {
+ "@commitlint/cli": "^19.5.0",
+ "@commitlint/config-conventional": "^19.5.0",
+ "husky": "^9.1.6"
+ }
+}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 0000000..e69de29
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..704331d
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,23 @@
+includes:
+ - phpstan-baseline.neon
+
+parameters:
+ # The level 9 is the highest level
+ level: 9
+
+ paths:
+ - src
+ - config
+
+ ignoreErrors:
+ # - '#PHPDoc tag @var#'
+ -
+ identifier: missingType.iterableValue
+
+ # excludePaths:
+ # - ./*/*/FileToBeExcluded.php
+
+ tmpDir: .results/phpstan
+
+ checkModelProperties: true
+ checkOctaneCompatibility: true
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..9b4a9d2
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,41 @@
+
+
+
+
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..44de855
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,195 @@
+{
+ "preset": "psr12",
+ "notPath": ["src/Patches/PatchedTranslator.php"],
+ "rules": {
+ "declare_strict_types": true,
+ "final_class": false,
+ "static_lambda": false,
+ "string_line_ending": true,
+ "date_time_immutable": true,
+ "function_typehint_space": true,
+ "no_unused_imports": false,
+ "no_superfluous_elseif": true,
+ "no_useless_else": true,
+ "phpdoc_summary": true,
+ "void_return": true,
+ "explicit_indirect_variable": true,
+ "fully_qualified_strict_types": true,
+ "implode_call": true,
+ "mb_str_functions": true,
+ "no_unneeded_curly_braces": true,
+ "no_unset_cast": true,
+ "no_useless_nullsafe_operator": true,
+ "php_unit_set_up_tear_down_visibility": true,
+ "phpdoc_scalar": true,
+ "single_line_comment_spacing": true,
+ "strict_param": true,
+ "ternary_to_null_coalescing": true,
+ "explicit_string_variable": true,
+ "list_syntax": true,
+ "no_alias_language_construct_call": true,
+ "no_blank_lines_after_phpdoc": true,
+ "no_short_bool_cast": true,
+ "no_useless_return": true,
+ "nullable_type_declaration_for_default_null_value": true,
+ "phpdoc_indent": true,
+ "phpdoc_order": true,
+ "protected_to_private": true,
+ "return_assignment": true,
+ "simplified_if_return": false,
+ "statement_indentation": true,
+ "switch_continue_to_break": true,
+ "array_syntax": {
+ "syntax": "short"
+ },
+ "cast_spaces": {
+ "space": "single"
+ },
+ "phpdoc_to_return_type": {
+ "scalar_types": true
+ },
+ "phpdoc_types_order": {
+ "null_adjustment": "always_last",
+ "sort_algorithm": "none"
+ },
+ "php_unit_method_casing": {
+ "case": "snake_case"
+ },
+ "php_unit_test_annotation": {
+ "style": "prefix"
+ },
+ "phpdoc_line_span": {
+ "const": "multi",
+ "method": "multi",
+ "property": "multi"
+ },
+ "phpdoc_no_alias_tag": {
+ "replacements": {
+ "property-read": "property-read",
+ "property-write": "property-write",
+ "type": "var",
+ "link": "see"
+ }
+ },
+ "concat_space": {
+ "spacing": "one"
+ },
+ "operator_linebreak": {
+ "only_booleans": false,
+ "position": "beginning"
+ },
+ "no_alias_functions": {
+ "sets": ["@all"]
+ },
+ "single_line_comment_style": {
+ "comment_types": ["asterisk", "hash"]
+ },
+ "trailing_comma_in_multiline": {
+ "after_heredoc": true,
+ "elements": ["arrays", "arguments", "parameters", "match"]
+ },
+ "blank_line_before_statement": {
+ "statements": [
+ "break",
+ "continue",
+ "declare",
+ "do",
+ "exit",
+ "for",
+ "foreach",
+ "if",
+ "include",
+ "include_once",
+ "phpdoc",
+ "require",
+ "require_once",
+ "return",
+ "switch",
+ "throw",
+ "try",
+ "while",
+ "yield",
+ "yield_from"
+ ]
+ },
+ "no_extra_blank_lines": {
+ "tokens": [
+ "attribute",
+ "break",
+ "case",
+ "continue",
+ "curly_brace_block",
+ "default",
+ "extra",
+ "parenthesis_brace_block",
+ "return",
+ "square_brace_block",
+ "switch",
+ "throw",
+ "use",
+ "use_trait"
+ ]
+ },
+ "ordered_class_elements": {
+ "order": [
+ "use_trait",
+ "case",
+
+ "constant_public",
+ "constant_protected",
+ "constant_private",
+
+ "property_public_static",
+ "property_protected_static",
+ "property_private_static",
+ "property_public_readonly",
+ "property_protected_readonly",
+ "property_private_readonly",
+ "property_public",
+ "property_protected",
+ "property_private",
+
+ "construct",
+ "destruct",
+
+ "method_public_abstract_static",
+ "method_protected_abstract_static",
+ "method_private_abstract_static",
+ "method_public_abstract",
+ "method_protected_abstract",
+ "method_private_abstract",
+ "method_public_static",
+ "method_protected_static",
+ "method_private_static",
+
+ "phpunit",
+
+ "method_public",
+ "method_protected",
+ "method_private",
+
+ "magic"
+ ]
+ },
+ "yoda_style": {
+ "always_move_variable": true,
+ "equal": false,
+ "identical": false,
+ "less_and_greater": false
+ },
+ "ordered_imports": {
+ "sort_algorithm": "length",
+ "imports_order": ["const", "function", "class"]
+ },
+ "global_namespace_import": {
+ "import_classes": true,
+ "import_constants": true,
+ "import_functions": true
+ },
+ "no_superfluous_phpdoc_tags": {
+ "allow_mixed": false,
+ "allow_unused_params": false,
+ "remove_inheritdoc": true
+ }
+ }
+}
diff --git a/src/Actions/Commands/AbstractCommandAction.php b/src/Actions/Commands/AbstractCommandAction.php
new file mode 100644
index 0000000..f904ee1
--- /dev/null
+++ b/src/Actions/Commands/AbstractCommandAction.php
@@ -0,0 +1,276 @@
+onlyLocales = $values;
+
+ return $this;
+ }
+
+ public function acceptsGroups(array $values): static
+ {
+ $this->onlyGroups = $values;
+
+ return $this;
+ }
+
+ public function acceptsNamespaces(array $values): static
+ {
+ $this->onlyNamespaces = $values;
+
+ return $this;
+ }
+
+ public function rejectsLocales(array $values): static
+ {
+ $this->exceptLocales = $values;
+
+ return $this;
+ }
+
+ public function rejectsGroups(array $values): static
+ {
+ $this->exceptGroups = $values;
+
+ return $this;
+ }
+
+ public function rejectsNamespaces(array $values): static
+ {
+ $this->exceptNamespaces = $values;
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(TranslationSet): void $callback
+ */
+ public function onTranslationSetSkipped(Closure $callback): static
+ {
+ $this->translationSetSkippedCallback = $callback;
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(TranslationSet): void $callback
+ */
+ public function onTranslationSetHandled(Closure $callback): static
+ {
+ $this->translationSetHandledCallback = $callback;
+
+ return $this;
+ }
+
+ /* Accessors
+ ------------------------------------------------*/
+
+ public function project(): ProjectConfiguration
+ {
+ return $this->project;
+ }
+
+ public function branch(): Branch
+ {
+ return $this->branch;
+ }
+
+ public function acceptedLocales(): array
+ {
+ return $this->onlyLocales;
+ }
+
+ public function acceptedGroups(): array
+ {
+ return $this->onlyGroups;
+ }
+
+ public function acceptedNamespaces(): array
+ {
+ return $this->onlyNamespaces;
+ }
+
+ public function rejectedLocales(): array
+ {
+ return $this->exceptLocales;
+ }
+
+ public function rejectedGroups(): array
+ {
+ return $this->exceptGroups;
+ }
+
+ public function rejectedNamespaces(): array
+ {
+ return $this->exceptNamespaces;
+ }
+
+ /* Hydration (bis)
+ ------------------------------------------------*/
+
+ protected function usingProject(ProjectConfiguration $project): static
+ {
+ $this->project = $project;
+
+ return $this;
+ }
+
+ protected function usingBranch(Branch $branch): static
+ {
+ $this->branch = $branch;
+
+ return $this;
+ }
+
+ /* Filters
+ ------------------------------------------------*/
+
+ protected function acceptsLocale(string $locale): bool
+ {
+ return $this->acceptsValue($locale, $this->acceptedLocales(), $this->rejectedLocales());
+ }
+
+ protected function acceptsGroup(?string $group): bool
+ {
+ return $this->acceptsValue($group, $this->acceptedGroups(), $this->rejectedGroups());
+ }
+
+ protected function acceptsNamespace(?string $namespace): bool
+ {
+ return $this->acceptsValue($namespace, $this->acceptedNamespaces(), $this->rejectedNamespaces());
+ }
+
+ protected function passesFilter(string $locale, ?string $group, ?string $namespace): bool
+ {
+ if (!$this->acceptsLocale($locale)) {
+ return false;
+ }
+
+ if (!$this->acceptsGroup($group)) {
+ return false;
+ }
+
+ if (!$this->acceptsNamespace($namespace)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @return callable(string $locale, ?string $group, ?string $namespace): bool
+ */
+ protected function passesFilterFactory(): callable
+ {
+ return function (string $locale, ?string $group, ?string $namespace): bool {
+ return $this->passesFilter($locale, $group, $namespace);
+ };
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function drivers(): ProjectConfigurationDriverCollection
+ {
+ return $this->project()->drivers;
+ }
+
+ protected function invokeTranslationSetSkippedCallback(TranslationSet $set): void
+ {
+ if (!$this->translationSetSkippedCallback) {
+ return;
+ }
+
+ ($this->translationSetSkippedCallback)($set);
+ }
+
+ protected function invokeTranslationSetHandledCallback(TranslationSet $set): void
+ {
+ if (!$this->translationSetHandledCallback) {
+ return;
+ }
+
+ ($this->translationSetHandledCallback)($set);
+ }
+
+ /* Helpers
+ ------------------------------------------------*/
+
+ protected function acceptsValue(?string $value, array $onlyList, array $exceptList): bool
+ {
+ if (!empty($exceptList) && in_array($value, $exceptList, true)) {
+ return false;
+ }
+
+ if (!empty($onlyList) && !in_array($value, $onlyList, true)) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/Actions/Commands/CountPushableTranslationSetsActions.php b/src/Actions/Commands/CountPushableTranslationSetsActions.php
new file mode 100644
index 0000000..8ab343c
--- /dev/null
+++ b/src/Actions/Commands/CountPushableTranslationSetsActions.php
@@ -0,0 +1,38 @@
+usingProject($project);
+ $this->usingBranch($branch);
+
+ $count = 0;
+
+ foreach ($this->drivers() as $driverClass => $driverParams) {
+ /** @var Driverable $driver */
+ $driver = app($driverClass, $driverParams);
+
+ $count = $count + $driver->countTranslationSets(
+ $this->project(),
+ $this->branch(),
+ $this->passesFilterFactory(),
+ );
+ }
+
+ return $count;
+ }
+}
diff --git a/src/Actions/Commands/InitCommandAction.php b/src/Actions/Commands/InitCommandAction.php
new file mode 100644
index 0000000..7d67768
--- /dev/null
+++ b/src/Actions/Commands/InitCommandAction.php
@@ -0,0 +1,122 @@
+usingProject($project);
+ $this->usingBranch($branch);
+
+ $this->startInitializationOnTransl();
+
+ $meta = [
+ ...$meta,
+ ...$this->meta(),
+ ];
+
+ $this
+ ->pushCommandAction()
+ ->acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces)
+ ->onTranslationSetSkipped($this->translationSetSkippedCallback ?: $this->noop())
+ ->onTranslationSetHandled($this->translationSetHandledCallback ?: $this->noop())
+ ->execute($project, $branch, $batch, $meta);
+
+ $this->endInitializationOnTransl();
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function startInitializationOnTransl(): void
+ {
+ Transl::api()->commands()->initStart(
+ $this->project(),
+ $this->branch(),
+ $this->determineDefaultBranchName(),
+ );
+ }
+
+ protected function endInitializationOnTransl(): void
+ {
+ Transl::api()->commands()->initEnd(
+ $this->project(),
+ $this->branch(),
+ );
+ }
+
+ protected function pushCommandAction(): PushCommandAction
+ {
+ return app(PushCommandAction::class);
+ }
+
+ protected function meta(): array
+ {
+ return [
+ 'unique_translation_key_count' => $this->getTotalUniqueTranslationKeyCount(),
+ ];
+ }
+
+ /* Helpers
+ ------------------------------------------------*/
+
+ protected function determineDefaultBranchName(): Branch
+ {
+ $branch = $this->project()->options->branching->default_branch_name ?: Git::defaultConfiguredBranchName();
+
+ if ($branch) {
+ return Branch::asDefault(trim($branch));
+ }
+
+ return Branch::asFallback(Transl::FALLBACK_BRANCH_NAME);
+ }
+
+ protected function noop(): Closure
+ {
+ return static fn () => null;
+ }
+
+ protected function getTotalUniqueTranslationKeyCount(): int
+ {
+ $count = 0;
+
+ foreach ($this->drivers() as $driverClass => $driverParams) {
+ /** @var Driverable $driver */
+ $driver = app($driverClass, $driverParams);
+
+ $translationSets = $driver->getTranslationSets(
+ $this->project(),
+ $this->branch(),
+ $this->passesFilterFactory(),
+ );
+
+ $analyzed = ProjectAnalysis::fromTranslationSets($translationSets);
+
+ $count = $count + $analyzed->summary->unique_translation_key_count;
+ }
+
+ return $count;
+ }
+}
diff --git a/src/Actions/Commands/PullCommandAction.php b/src/Actions/Commands/PullCommandAction.php
new file mode 100644
index 0000000..d165845
--- /dev/null
+++ b/src/Actions/Commands/PullCommandAction.php
@@ -0,0 +1,296 @@
+usingProject($project);
+ $this->usingBranch($branch);
+ $this->usingConflictResolution($conflictResolution ?: $this->defaultConflictResolution());
+
+ foreach ($this->pullFromTransl() as $incoming) {
+ if (!$this->passesFilter($incoming->locale, $incoming->group, $incoming->namespace)) {
+ $this->invokeTranslationSetSkippedCallback($incoming);
+
+ continue;
+ }
+
+ foreach ($this->drivers() as $driverClass => $driverParams) {
+ /** @var Driverable $driver */
+ $driver = app($driverClass, $driverParams);
+
+ $current = $this->getCurrentTranslationSet($driver, $incoming);
+ $tracked = $this->getTrackedTranslationSet($driver, $incoming);
+
+ $diff = $this->getIncomingTranslationSetDiff($tracked, $current, $incoming);
+
+ if (!$tracked) {
+ $this->savePreviouslyUntrackedTranslationSet($driver, $incoming, $diff);
+
+ continue;
+ }
+
+ if ($this->conflictResolution() === BranchingConflictResolutionEnum::ACCEPT_INCOMING) {
+ $this->saveAcceptingIncomingTranslationSet($driver, $incoming, $diff);
+
+ continue;
+ }
+
+ if ($this->conflictResolution() === BranchingConflictResolutionEnum::ACCEPT_CURRENT) {
+ $this->saveAcceptingCurrentTranslationSet($driver, $incoming, $diff);
+
+ continue;
+ }
+
+ $hasConflics = $diff->conflictingLines()->isNotEmpty();
+
+ if ($hasConflics) {
+ $this->invokeIncomingTranslationSetConflictsHandler($incoming, $diff);
+ }
+
+ if ($hasConflics && ($this->conflictResolution() === BranchingConflictResolutionEnum::THROW)) {
+ if ($this->shouldSilenceConflictExceptions) {
+ continue;
+ }
+
+ throw CouldNotResolveConflictWhilePulling::make($this->project(), $this->branch(), $incoming);
+ }
+
+ if ($hasConflics && ($this->conflictResolution() === BranchingConflictResolutionEnum::IGNORE)) {
+ continue;
+ }
+
+ $this->saveConsideringTranslationSetConflictingLines($driver, $incoming, $diff);
+
+ if ($hasConflics && ($this->conflictResolution() === BranchingConflictResolutionEnum::MERGE_BUT_THROW)) {
+ if ($this->shouldSilenceConflictExceptions) {
+ continue;
+ }
+
+ throw CouldNotResolveConflictWhilePulling::make($this->project(), $this->branch(), $incoming);
+ }
+ }
+
+ $this->invokeTranslationSetHandledCallback($incoming);
+ }
+ }
+
+ /* Hydration
+ ------------------------------------------------*/
+
+ public function silenceConflictExceptions(): static
+ {
+ $this->shouldSilenceConflictExceptions = true;
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(TranslationSet, TranslationLinesDiffing ): void $callback
+ */
+ public function onIncomingTranslationSetConflicts(Closure $callback): static
+ {
+ $this->incomingTranslationSetConflictsHandler = $callback;
+
+ return $this;
+ }
+
+ /* Accessors
+ ------------------------------------------------*/
+
+ public function conflictResolution(): BranchingConflictResolutionEnum
+ {
+ return $this->conflictResolution;
+ }
+
+ /* Hydration (bis)
+ ------------------------------------------------*/
+
+ protected function usingConflictResolution(BranchingConflictResolutionEnum $value): static
+ {
+ $this->conflictResolution = $value;
+
+ return $this;
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ /**
+ * @return Generator
+ */
+ protected function pullFromTransl(): Generator
+ {
+ /** @var PullResponse|null $response */
+ $response = null;
+
+ $filters = array_filter([
+ 'only_locales' => implode(',', $this->acceptedLocales()),
+ 'only_groups' => implode(',', $this->acceptedGroups()),
+ 'only_namespaces' => implode(',', $this->acceptedNamespaces()),
+ 'except_locales' => implode(',', $this->rejectedLocales()),
+ 'except_groups' => implode(',', $this->rejectedGroups()),
+ 'except_namespaces' => implode(',', $this->rejectedNamespaces()),
+ ]);
+
+ do {
+ $response = Transl::api()->commands()->pull(
+ $this->project(),
+ $this->branch(),
+ [
+ ...$filters,
+ 'cursor' => $response?->pagination->next_cursor,
+ ],
+ );
+
+ /** @var Set $set */
+ foreach ($response->data as $set) {
+ yield TranslationSet::new(
+ locale: $set->attributes->locale,
+ group: $set->attributes->group,
+ namespace: $set->attributes->namespace,
+ lines: TranslationLineCollection::make(
+ array_map(static function (Line $line): TranslationLine {
+ return TranslationLine::make(
+ key: $line->attributes->key,
+ value: $line->attributes->value,
+ meta: $line->meta,
+ );
+ }, $set->relations->lines),
+ ),
+ meta: $set->meta,
+ );
+ }
+ } while ($response->pagination->has_more_pages);
+ }
+
+ protected function getCurrentTranslationSet(Driverable $driver, TranslationSet $incoming): TranslationSet
+ {
+ return $driver->getTranslationSet(
+ $this->project(),
+ $this->branch(),
+ $incoming->locale,
+ $incoming->group,
+ $incoming->namespace,
+ null,
+ );
+ }
+
+ protected function getTrackedTranslationSet(Driverable $driver, TranslationSet $incoming): ?TranslationSet
+ {
+ return $driver->getTrackedTranslationSet($this->project(), $this->branch(), $incoming);
+ }
+
+ protected function getIncomingTranslationSetDiff(?TranslationSet $tracked, TranslationSet $current, TranslationSet $incoming): TranslationLinesDiffing
+ {
+ return $incoming->diff(
+ trackedLines: $tracked ?: TranslationLineCollection::make(),
+ currentLines: $current,
+ );
+ }
+
+ protected function savePreviouslyUntrackedTranslationSet(Driverable $driver, TranslationSet $incoming, TranslationLinesDiffing $diff): void
+ {
+ $this->saveConsideringTranslationSetConflictingLines($driver, $incoming, $diff);
+ }
+
+ protected function saveAcceptingIncomingTranslationSet(Driverable $driver, TranslationSet $incoming, TranslationLinesDiffing $diff): void
+ {
+ $this->save(
+ driver: $driver,
+ incoming: $incoming,
+ saveableLines: $diff->favorIncomingLines(),
+ );
+ }
+
+ protected function saveAcceptingCurrentTranslationSet(Driverable $driver, TranslationSet $incoming, TranslationLinesDiffing $diff): void
+ {
+ $this->save(
+ driver: $driver,
+ incoming: $incoming,
+ saveableLines: $diff->favorCurrentLines(),
+ );
+ }
+
+ protected function saveConsideringTranslationSetConflictingLines(Driverable $driver, TranslationSet $incoming, TranslationLinesDiffing $diff): void
+ {
+ $this->save(
+ driver: $driver,
+ incoming: $incoming,
+ saveableLines: $diff->mergeableLines(),
+ );
+ }
+
+ protected function save(Driverable $driver, TranslationSet $incoming, TranslationLineCollection $saveableLines): void
+ {
+ $saveable = TranslationSet::new(
+ $incoming->locale,
+ $incoming->group,
+ $incoming->namespace,
+ $saveableLines,
+ $incoming->meta,
+ );
+
+ $driver->saveTranslationSet($this->project(), $this->branch(), $saveable);
+ }
+
+ protected function invokeIncomingTranslationSetConflictsHandler(TranslationSet $incoming, TranslationLinesDiffing $diff): void
+ {
+ if (!$this->incomingTranslationSetConflictsHandler) {
+ return;
+ }
+
+ ($this->incomingTranslationSetConflictsHandler)($incoming, $diff);
+ }
+
+ /* Helpers
+ ------------------------------------------------*/
+
+ protected function defaultConflictResolution(): BranchingConflictResolutionEnum
+ {
+ return $this->project()->options->branching->conflict_resolution;
+ }
+}
diff --git a/src/Actions/Commands/PushCommandAction.php b/src/Actions/Commands/PushCommandAction.php
new file mode 100644
index 0000000..de6ebf0
--- /dev/null
+++ b/src/Actions/Commands/PushCommandAction.php
@@ -0,0 +1,140 @@
+message = $value;
+
+ return $this;
+ }
+
+ /**
+ * Execute the action.
+ */
+ public function execute(ProjectConfiguration $project, Branch $branch, ?PushBatch $batch = null, array $meta = []): void
+ {
+ $this->usingProject($project);
+ $this->usingBranch($branch);
+
+ if (!$batch) {
+ $batch = PushBatch::new($this->count());
+ }
+
+ $filter = $this->passesFilterFactory();
+ $onSkipped = $this->translationSetSkippedCallback
+ ? fn (TranslationSet $translationSet) => $this->invokeTranslationSetSkippedCallback($translationSet)
+ : null;
+
+ foreach ($this->drivers() as $driverClass => $driverParams) {
+ /** @var Driverable $driver */
+ $driver = app($driverClass, $driverParams);
+
+ $translationSets = $driver->getTranslationSets(
+ $this->project(),
+ $this->branch(),
+ $filter,
+ $onSkipped,
+ );
+
+ $push = fn () => $this->push($batch, $meta, $driver);
+
+ foreach ($translationSets as $translationSet) {
+ $batch->addUntilPoolFull($translationSet, $push);
+
+ if (!$translationSet->group) {
+ $batch->ensurePoolDrained($push);
+ }
+ }
+
+ $batch->ensurePoolDrained($push);
+ }
+
+ if (!$batch->totalPushed()) {
+ return;
+ }
+
+ $this->markPushAsEndedOnTransl($batch);
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function count(): int
+ {
+ return app(CountPushableTranslationSetsActions::class)
+ ->acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces)
+ ->execute($this->project(), $this->branch());
+ }
+
+ protected function push(PushBatch $batch, array $meta, Driverable $driver): void
+ {
+ $this->pushToTransl($batch, $meta, function (PushChunk $chunk) use ($driver): void {
+ $this->savePushed($driver, $chunk);
+ });
+ }
+
+ protected function savePushed(Driverable $driver, PushChunk $chunk): void
+ {
+ foreach ($chunk->translationSets() as $translationSet) {
+ $driver->saveTrackedTranslationSet($this->project(), $this->branch(), $translationSet);
+
+ $this->invokeTranslationSetHandledCallback($translationSet);
+ }
+ }
+
+ /**
+ * @param callable(PushChunk $chunk): void $onPushed
+ */
+ protected function pushToTransl(PushBatch $batch, array $meta, callable $onPushed): void
+ {
+ Transl::api()->commands()->push(
+ $this->project(),
+ $this->branch(),
+ $batch,
+ $onPushed,
+ $meta,
+ );
+ }
+
+ protected function markPushAsEndedOnTransl(PushBatch $batch): void
+ {
+ Transl::api()->commands()->pushEnd(
+ $this->project(),
+ $this->branch(),
+ $batch,
+ [
+ 'message' => $this->message,
+ ],
+ );
+ }
+}
diff --git a/src/Actions/Commands/SynchCommandAction.php b/src/Actions/Commands/SynchCommandAction.php
new file mode 100644
index 0000000..f9ed6bb
--- /dev/null
+++ b/src/Actions/Commands/SynchCommandAction.php
@@ -0,0 +1,184 @@
+usingProject($project);
+ $this->usingBranch($branch);
+
+ $this->pull($project, $branch, $conflictResolution);
+ $this->push($project, $branch, $batch);
+ }
+
+ /* Hydration
+ ------------------------------------------------*/
+
+ /**
+ * @param Closure(TranslationSet): void $callback
+ */
+ public function onPulledTranslationSetSkipped(Closure $callback): static
+ {
+ $this->pulledTranslationSetSkippedCallback = $callback;
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(TranslationSet): void $callback
+ */
+ public function onPulledTranslationSetHandled(Closure $callback): static
+ {
+ $this->pulledTranslationSetHandledCallback = $callback;
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(TranslationSet): void $callback
+ */
+ public function onPushedTranslationSetSkipped(Closure $callback): static
+ {
+ $this->pushedTranslationSetSkippedCallback = $callback;
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(TranslationSet): void $callback
+ */
+ public function onPushedTranslationSetHandled(Closure $callback): static
+ {
+ $this->pushedTranslationSetHandledCallback = $callback;
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(TranslationSet, TranslationLinesDiffing ): void $callback
+ */
+ public function onIncomingTranslationSetConflicts(Closure $callback): static
+ {
+ $this->incomingTranslationSetConflictsHandler = $callback;
+
+ return $this;
+ }
+
+ public function silenceConflictExceptions(): static
+ {
+ $this->shouldSilenceConflictExceptions = true;
+
+ return $this;
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function pullCommandAction(): PullCommandAction
+ {
+ return app(PullCommandAction::class);
+ }
+
+ protected function pushCommandAction(): PushCommandAction
+ {
+ return app(PushCommandAction::class);
+ }
+
+ protected function pull(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?BranchingConflictResolutionEnum $conflictResolution = null,
+ ): void {
+ $action = $this
+ ->pullCommandAction()
+ ->acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces);
+
+ if ($this->shouldSilenceConflictExceptions) {
+ $action->silenceConflictExceptions();
+ }
+
+ $action
+ ->onTranslationSetSkipped($this->pulledTranslationSetSkippedCallback ?: $this->translationSetSkippedCallback ?: $this->noop())
+ ->onTranslationSetHandled($this->pulledTranslationSetHandledCallback ?: $this->translationSetHandledCallback ?: $this->noop())
+ ->onIncomingTranslationSetConflicts($this->incomingTranslationSetConflictsHandler ?: $this->noop())
+ ->execute($project, $branch, $conflictResolution);
+ }
+
+ protected function push(ProjectConfiguration $project, Branch $branch, ?PushBatch $batch = null): void
+ {
+ $this
+ ->pushCommandAction()
+ ->acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces)
+ ->onTranslationSetSkipped($this->pushedTranslationSetSkippedCallback ?: $this->translationSetSkippedCallback ?: $this->noop())
+ ->onTranslationSetHandled($this->pushedTranslationSetHandledCallback ?: $this->translationSetHandledCallback ?: $this->noop())
+ ->execute($project, $branch, $batch);
+ }
+
+ protected function noop(): Closure
+ {
+ return static fn () => null;
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/AbstractLocalFilesDriverAction.php b/src/Actions/LocalFilesDriver/AbstractLocalFilesDriverAction.php
new file mode 100644
index 0000000..3feb12f
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/AbstractLocalFilesDriverAction.php
@@ -0,0 +1,61 @@
+driver = $driver;
+
+ return $this;
+ }
+
+ public function usingProject(ProjectConfiguration $project): static
+ {
+ $this->project = $project;
+
+ return $this;
+ }
+
+ public function usingBranch(Branch $branch): static
+ {
+ $this->branch = $branch;
+
+ return $this;
+ }
+
+ public function usingLanguageDirectories(array $languageDirectories): static
+ {
+ $this->languageDirectories = $languageDirectories;
+
+ return $this;
+ }
+
+ public function shouldIgnorePackageTranslations(bool $ignorePackageTranslations): static
+ {
+ $this->ignorePackageTranslations = $ignorePackageTranslations;
+
+ return $this;
+ }
+
+ public function shouldIgnoreVendorTranslations(bool $ignoreVendorTranslations): static
+ {
+ $this->ignoreVendorTranslations = $ignoreVendorTranslations;
+
+ return $this;
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/AbstractLocalFilesRetrievalAction.php b/src/Actions/LocalFilesDriver/AbstractLocalFilesRetrievalAction.php
new file mode 100644
index 0000000..12dd600
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/AbstractLocalFilesRetrievalAction.php
@@ -0,0 +1,171 @@
+filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * @param (Closure(TranslationSet $translationSet): void)|null $onSkipped
+ */
+ public function onSkipped(?Closure $onSkipped): static
+ {
+ $this->onSkipped = $onSkipped;
+
+ return $this;
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function recursivelyReadDirectory(FilePath $directory, ?FilePath $root = null): Generator
+ {
+ if (!$root) {
+ $root = $directory;
+ }
+
+ $handle = $directory->exists() ? opendir($directory->fullPath()) : null;
+
+ if (!$handle) {
+ throw CouldNotOpenLanguageDirectory::make($directory, $this->driver::class);
+ }
+
+ while (($path = readdir($handle)) !== false) {
+ if ($path === '.' || $path === '..') {
+ continue;
+ }
+
+ $relativePath = $directory->append($path)->relativeFrom($root);
+ $path = LangFilePath::new($root->fullPath(), $relativePath);
+
+ if ($path->isDirectory()) {
+ foreach ($this->recursivelyReadDirectory($path, $root) as $value) {
+ yield $value;
+ }
+ } else {
+ yield $path;
+ }
+ }
+
+ closedir($handle);
+ }
+
+ protected function shouldIgnoreTranslationFile(LangFilePath $translationFile, FilePath $languageDirectory): bool
+ {
+ if ($this->ignorePackageTranslations && $translationFile->isPackage()) {
+ return true;
+ }
+
+ return $this->ignoreVendorTranslations && $translationFile->inVendor();
+ }
+
+ protected function allowsTranslationFileLocale(LangFilePath $translationFile, FilePath $languageDirectory): bool
+ {
+ $option = $this->project->options->locale;
+
+ if (is_null($option->allowed)) {
+ return true;
+ }
+
+ $locale = $this->getTranslationFileLocale($translationFile, $languageDirectory);
+
+ if (in_array($locale, $option->allowed, true)) {
+ return true;
+ }
+
+ if (!$option->throw_on_disallowed_locale) {
+ return false;
+ }
+
+ throw FoundDisallowedProjectLocale::make($locale, $this->project);
+ }
+
+ protected function passesFilter(
+ LangFilePath $translationFile,
+ FilePath $languageDirectory,
+ ): bool {
+ $locale = $this->getTranslationFileLocale($translationFile, $languageDirectory);
+ $group = $this->getTranslationFileGroup($translationFile, $languageDirectory);
+ $namespace = $this->getTranslationFileNamespace($translationFile, $languageDirectory);
+
+ if (!$this->filter) {
+ return true;
+ }
+
+ return ($this->filter)($locale, $group, $namespace);
+ }
+
+ protected function getTranslationFileLocale(LangFilePath $translationFile, FilePath $languageDirectory): string
+ {
+ return $translationFile->guessLocale($languageDirectory);
+ }
+
+ protected function getTranslationFileGroup(LangFilePath $translationFile, FilePath $languageDirectory): ?string
+ {
+ return $translationFile->guessGroup($languageDirectory);
+ }
+
+ protected function getTranslationFileNamespace(LangFilePath $translationFile, FilePath $languageDirectory): ?string
+ {
+ return $translationFile->guessNamespace($languageDirectory);
+ }
+
+ protected function makeTranslationSetFromTranslationFile(
+ LangFilePath $translationFile,
+ FilePath $languageDirectory,
+ ): TranslationSet {
+ $locale = $this->getTranslationFileLocale($translationFile, $languageDirectory);
+ $group = $this->getTranslationFileGroup($translationFile, $languageDirectory);
+ $namespace = $this->getTranslationFileNamespace($translationFile, $languageDirectory);
+
+ return $this->driver->getTranslationSet(
+ project: $this->project,
+ branch: $this->branch,
+ locale: $locale,
+ group: $group,
+ namespace: $namespace,
+ meta: null,
+ );
+ }
+
+ protected function handleSkipped(LangFilePath $translationFile, FilePath $languageDirectory): void
+ {
+ if (!$this->onSkipped) {
+ return;
+ }
+
+ $translationSet = $this->makeTranslationSetFromTranslationFile($translationFile, $languageDirectory);
+
+ ($this->onSkipped)($translationSet);
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/CountTranslationSetsFromLocalFilesAction.php b/src/Actions/LocalFilesDriver/CountTranslationSetsFromLocalFilesAction.php
new file mode 100644
index 0000000..fb2e737
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/CountTranslationSetsFromLocalFilesAction.php
@@ -0,0 +1,43 @@
+languageDirectories as $languageDirectory) {
+ $languageDirectory = FilePath::new($languageDirectory);
+
+ foreach ($this->recursivelyReadDirectory($languageDirectory) as $translationFile) {
+ if ($this->shouldIgnoreTranslationFile($translationFile, $languageDirectory)) {
+ continue;
+ }
+
+ if (!$this->allowsTranslationFileLocale($translationFile, $languageDirectory)) {
+ continue;
+ }
+
+ if (!$this->passesFilter($translationFile, $languageDirectory)) {
+ $this->handleSkipped($translationFile, $languageDirectory);
+
+ continue;
+ }
+
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/GetTrackedTranslationSetFromLocalFilesAction.php b/src/Actions/LocalFilesDriver/GetTrackedTranslationSetFromLocalFilesAction.php
new file mode 100644
index 0000000..8b9bfb5
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/GetTrackedTranslationSetFromLocalFilesAction.php
@@ -0,0 +1,28 @@
+driver->getTrackedTranslationSetPath($this->project, $this->branch, $set);
+ $filesystem = $this->driver->filesystem();
+
+ if (!$path || !$filesystem->exists($path)) {
+ return null;
+ }
+
+ $raw = $filesystem->json($path);
+
+ return TranslationSet::from($raw);
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesAction.php b/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesAction.php
new file mode 100644
index 0000000..de1602f
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesAction.php
@@ -0,0 +1,30 @@
+driver->translationLoader();
+
+ if (is_null($group) && is_null($namespace)) {
+ $group = '*';
+ $namespace = '*';
+ }
+
+ if (!$group) {
+ throw MissingRequiredTranslationSetGroup::make($this->driver::class, 'getTranslationContents');
+ }
+
+ return $loader->load($locale, $group, $namespace);
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesAction.php b/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesAction.php
new file mode 100644
index 0000000..52ea7ab
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesAction.php
@@ -0,0 +1,42 @@
+
+ */
+ public function execute(): iterable
+ {
+ foreach ($this->languageDirectories as $languageDirectory) {
+ $languageDirectory = FilePath::new($languageDirectory);
+
+ foreach ($this->recursivelyReadDirectory($languageDirectory) as $translationFile) {
+ if ($this->shouldIgnoreTranslationFile($translationFile, $languageDirectory)) {
+ continue;
+ }
+
+ if (!$this->allowsTranslationFileLocale($translationFile, $languageDirectory)) {
+ continue;
+ }
+
+ if (!$this->passesFilter($translationFile, $languageDirectory)) {
+ $this->handleSkipped($translationFile, $languageDirectory);
+
+ continue;
+ }
+
+ yield $this->makeTranslationSetFromTranslationFile($translationFile, $languageDirectory);
+ }
+ }
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/SaveTrackedTranslationSetToLocalFilesAction.php b/src/Actions/LocalFilesDriver/SaveTrackedTranslationSetToLocalFilesAction.php
new file mode 100644
index 0000000..44b457c
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/SaveTrackedTranslationSetToLocalFilesAction.php
@@ -0,0 +1,28 @@
+driver->getTrackedTranslationSetPath($this->project, $this->branch, $set);
+
+ if (!$path) {
+ return;
+ }
+
+ $this->driver->filesystem()->ensureDirectoryExists(dirname($path));
+
+ $this->driver->filesystem()->put($path, Helper::jsonEncode($set->toArray()));
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesAction.php b/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesAction.php
new file mode 100644
index 0000000..b5361fa
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesAction.php
@@ -0,0 +1,165 @@
+translationSet = $translationSet;
+
+ return $this;
+ }
+
+ /**
+ * Execute the action.
+ */
+ public function execute(): void
+ {
+ $translationFile = LangFilePath::new(
+ $this->determineTranslationFileRoot(),
+ $this->determineTranslationFileRelativePath(),
+ );
+
+ $this->writeToTranslationFile($translationFile);
+ }
+
+ protected function defaultTranslationFileRoot(): string
+ {
+ return $this->driver->defaultLanguageDirectories($this->project, $this->branch)[0] ?? lang_path();
+ }
+
+ protected function determineTranslationFileRoot(): string
+ {
+ $default = $this->defaultTranslationFileRoot();
+
+ $root = str_replace('\\', '/', $default);
+
+ $languageDirectories = collect($this->languageDirectories)
+ ->map(static fn (string $languageDirectory): string => str_replace('\\', '/', $languageDirectory))
+ ->filter(static fn (string $languageDirectory): bool => !str_contains($languageDirectory, 'vendor/'));
+
+ $languageDirectory = $languageDirectories
+ ->first(static fn (string $languageDirectory): bool => $root === $languageDirectory);
+
+ if (!$languageDirectory) {
+ $languageDirectory = $languageDirectories->first() ?: $default;
+ }
+
+ return $languageDirectory;
+ }
+
+ protected function determineTranslationFileRelativePath(): string
+ {
+ $set = $this->translationSet;
+
+ if ($set->namespace && $set->group) {
+ return "vendor/{$set->namespace}/{$set->locale}/{$set->group}.php";
+ }
+
+ /**
+ * This is not a case, having packages provide JSON
+ * translation files, handled by Laravel's FileLoader.
+ * Therefore, we shouldn't encounter nor handle it.
+ *
+ * @see vendor/laravel/framework/src/Illuminate/Translation/FileLoader.php@load
+ */
+ // if ($set->namespace && !$set->group) {
+ // return "vendor/{$set->namespace}/{$set->locale}.json";
+ // }
+
+ if (!$set->namespace && $set->group) {
+ return "{$set->locale}/{$set->group}.php";
+ }
+
+ if (!$set->namespace && !$set->group) {
+ return "{$set->locale}.json";
+ }
+
+ throw CouldNotDetermineTranslationFileRelativePathFromTranslationSet::make($set, $this->driver::class);
+ }
+
+ protected function writeToTranslationFile(LangFilePath $translationFile): void
+ {
+ $translationFile->isJson()
+ ? $this->writeToJsonTranslationFile($translationFile)
+ : $this->writeToPhpTranslationFile($translationFile);
+ }
+
+ protected function writeToJsonTranslationFile(LangFilePath $translationFile): void
+ {
+ $content = $this->translationSet->lines->toRawTranslationLinesWithPotentiallyOriginalValues();
+ $content = Helper::jsonEncode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+ $this->driver->filesystem()->ensureDirectoryExists(dirname($translationFile->fullPath()));
+ $this->driver->filesystem()->put($translationFile->fullPath(), $content);
+ }
+
+ protected function writeToPhpTranslationFile(LangFilePath $translationFile): void
+ {
+ $content = $this->translationSet->lines->toRawTranslationLinesWithPotentiallyOriginalValues();
+ $content = Arr::undot($content);
+
+ $content = <<phpArrayToString($content)}
+ ];
+
+ PHP;
+
+ $this->driver->filesystem()->ensureDirectoryExists(dirname($translationFile->fullPath()));
+ $this->driver->filesystem()->put($translationFile->fullPath(), $content);
+ }
+
+ protected function phpArrayToString(array $items, int $depth = 1, int $indentSpaces = 4): string
+ {
+ $indentation = str_repeat(' ', $indentSpaces * $depth);
+ $indentationMinus1 = str_repeat(' ', $indentSpaces * ($depth - 1));
+
+ $n = PHP_EOL;
+
+ $content = $depth === 1 ? $n : "[{$n}";
+
+ $asList = Arr::isList($items);
+
+ foreach ($items as $key => $value) {
+ $key = $this->varExport($key);
+
+ if (!is_array($value)) {
+ $value = $this->varExport($value);
+ }
+
+ if (is_array($value)) {
+ $value = empty($value) ? '[]' : $this->phpArrayToString($value, $depth + 1, $indentSpaces);
+ }
+
+ if ($asList) {
+ $content .= "{$indentation}{$value},{$n}";
+ } else {
+ $content .= "{$indentation}{$key} => {$value},{$n}";
+ }
+ }
+
+ $content .= $depth === 1 ? '' : "{$indentationMinus1}]";
+
+ return trim($content);
+ }
+
+ protected function varExport(mixed $value): string
+ {
+ return $value === null ? 'null' : var_export($value, true);
+ }
+}
diff --git a/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetAction.php b/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetAction.php
new file mode 100644
index 0000000..f9c2fb7
--- /dev/null
+++ b/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetAction.php
@@ -0,0 +1,98 @@
+fromRawTranslationFileContentsToRawTranslationLines($contents);
+
+ return TranslationSet::new(
+ locale: $locale,
+ group: $group,
+ namespace: $namespace,
+ lines: TranslationLineCollection::fromRawTranslationLines($lines),
+ meta: $meta,
+ );
+ }
+
+ protected function fromRawTranslationFileContentsToRawTranslationLines(
+ array $contents,
+ ?string $parentKey = null,
+ ): array {
+ return collect(Arr::dot($contents))
+ ->mapWithKeys(function (mixed $value, string $key) use ($parentKey): array {
+ $value = $this->rawTranslationLineValue($value);
+
+ if (is_array($value) && !empty($value)) {
+ return $this->fromRawTranslationFileContentsToRawTranslationLines($value, $key);
+ }
+
+ if ($parentKey) {
+ $key = "{$parentKey}.{$key}";
+ }
+
+ return [
+ $key => $value,
+ ];
+ })
+ ->toArray();
+ }
+
+ protected function rawTranslationLineValue(mixed $value): mixed
+ {
+ if ($value instanceof Closure) {
+ return $this->rawTranslationLineValue(rescue($value));
+ }
+
+ if ($this->valueIsArrayConvertable($value)) {
+ /**
+ * Making use of the collection's `getArrayableItems`
+ * method here to convert array-like values to an array.
+ *
+ * Also using `toArray` to convert inner `Arrayable`
+ * values into an array.
+ */
+ // @phpstan-ignore-next-line
+ $value = collect($value)->toArray();
+ }
+
+ return $value;
+ }
+
+ protected function valueIsArrayConvertable(mixed $value): bool
+ {
+ if (is_array($value)) {
+ return true;
+ }
+
+ if (!is_object($value)) {
+ return false;
+ }
+
+ if (($value instanceof Stringable) && !($value instanceof Enumerable)) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/Actions/Reports/ReportMissingTranslationKeysAction.php b/src/Actions/Reports/ReportMissingTranslationKeysAction.php
new file mode 100644
index 0000000..b67d53d
--- /dev/null
+++ b/src/Actions/Reports/ReportMissingTranslationKeysAction.php
@@ -0,0 +1,26 @@
+missingTranslationKeys()->add($key, $replacements, $locale, $fallback);
+
+ return $key;
+ }
+}
diff --git a/src/Actions/Reports/SendMissingTranslationKeyReportAction.php b/src/Actions/Reports/SendMissingTranslationKeyReportAction.php
new file mode 100644
index 0000000..59e9ef1
--- /dev/null
+++ b/src/Actions/Reports/SendMissingTranslationKeyReportAction.php
@@ -0,0 +1,67 @@
+ $reports
+ */
+ public function execute(array $reports): void
+ {
+ $this->reportGroups($this->groupReports($reports));
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ /**
+ * @param array $reports
+ * @return Collection>
+ */
+ protected function groupReports(array $reports): Collection
+ {
+ return collect($reports)->groupBy(static fn (MissingTranslationKeyReport $report): string => $report->group());
+ }
+
+ /**
+ * @param Collection> $group
+ */
+ protected function reportGroups(Collection $group): void
+ {
+ foreach ($group as $reports) {
+ /** @var MissingTranslationKeyReport $report */
+ $report = $reports->first();
+
+ $project = $report->project;
+ $branch = $report->branch;
+
+ $keys = $reports->reduce(static function (array $acc, MissingTranslationKeyReport $report): array {
+ $acc[$report->key->id()] = $report->key;
+
+ return $acc;
+ }, []);
+
+ $this->reportToTransl($project, $branch, $keys);
+ }
+ }
+
+ /**
+ * @param array $keys
+ */
+ protected function reportToTransl(ProjectConfiguration $project, Branch $branch, array $keys): void
+ {
+ Transl::api()->reports()->missingTranslationKey($project, $branch, $keys);
+ }
+}
diff --git a/src/Api/Client.php b/src/Api/Client.php
new file mode 100644
index 0000000..9b16de9
--- /dev/null
+++ b/src/Api/Client.php
@@ -0,0 +1,256 @@
+authKey = $key;
+
+ return $this;
+ }
+
+ /**
+ * Sets the project to target from which the authentication
+ * key will be extracted..
+ */
+ public function withProject(ProjectConfiguration $project): static
+ {
+ return $this->withAuthKey($project->auth_key);
+ }
+
+ /**
+ * Sets the branch of the project to target.
+ */
+ public function withBranch(Branch $branch): static
+ {
+ $this->branch = $branch;
+
+ return $this;
+ }
+
+ /**
+ * Request setup.
+ */
+ public function http(): PendingRequest
+ {
+ $request = $this->baseHttp();
+
+ if ($this->authKey) {
+ $request->withToken($this->authKey);
+ }
+
+ if ($this->branch) {
+ $request->withHeaders([
+ 'X-Transl-Branch-Name' => $this->branch->name,
+ 'X-Transl-Branch-Provenance' => $this->branch->provenance(),
+ ]);
+ }
+
+ if (static::$retryOnTooManyRequests) {
+ $this->setupRetry($request);
+ }
+
+ return $request;
+ }
+
+ /**
+ * Make concurrent requests.
+ *
+ * @template TItem
+ *
+ * @param iterable $items
+ * @param callable(PendingRequest $request, TItem $item): void $callback
+ * @return (Response|GuzzleException)[]
+ */
+ public function pool(iterable $items, callable $callback): array
+ {
+ $responses = $this->basePool($items, $callback);
+
+ if ($this->shouldPatchAsyncRequestExceptionHandling()) {
+ $this->patchAsyncRequestExceptionHandling($responses);
+ }
+
+ return $responses;
+ }
+
+ protected function baseHttp(): PendingRequest
+ {
+ $versions = Transl::versions();
+ $packageName = Transl::PACKAGE_NAME;
+
+ $request = static::$makePendingRequestCallback
+ ? (static::$makePendingRequestCallback)()
+ : Http::baseUrl(static::BASE_URL);
+
+ return $request
+ ->withUserAgent($this->makeUserAgent($packageName, $versions))
+ ->withHeaders([
+ 'X-Transl-Package-Name' => $packageName,
+ 'X-Transl-Package-Version' => $versions->package,
+ 'X-Transl-Framework-Name' => 'Laravel',
+ 'X-Transl-Framework-Version' => $versions->laravel,
+ 'X-Transl-Language-Name' => 'PHP',
+ 'X-Transl-Language-Version' => $versions->php,
+ ])
+ ->acceptJson()
+ ->asJson()
+ ->throw();
+ }
+
+ /**
+ * @template TItem
+ *
+ * @param iterable $items
+ * @param callable(PendingRequest $request, TItem $item): void $callback
+ * @return (Response|GuzzleException)[]
+ */
+ protected function basePool(iterable $items, callable $callback): array
+ {
+ $pendingRequest = $this->http();
+
+ // Extracting out the `PendingRequest#baseUrl` non-public property value
+ $baseUrl = Closure::bind(
+ function (): string {
+ /** @var PendingRequest $this */
+ return $this->baseUrl;
+ },
+ $pendingRequest,
+ PendingRequest::class,
+ )();
+
+ $options = $pendingRequest->getOptions();
+
+ return $pendingRequest->pool(function (Pool $pool) use ($items, $callback, $baseUrl, $options): void {
+ foreach ($items as $item) {
+ /** @var PendingRequest $request */
+ $request = $pool->baseUrl($baseUrl)->withOptions($options);
+
+ if (static::$retryOnTooManyRequests) {
+ $this->setupRetry($request);
+ }
+
+ $callback($request, $item);
+ }
+ });
+ }
+
+ protected function makeUserAgent(string $packageName, Versions $versions): string
+ {
+ $packageName = str_replace('/', '___', $packageName);
+
+ return "{$packageName}/{$versions->package} laravel/{$versions->laravel} php/{$versions->php}";
+ }
+
+ protected function setupRetry(PendingRequest $request): PendingRequest
+ {
+ return $request->retry(2, 0, $this->makeTooManyRequestsRetryHandler());
+ }
+
+ protected function makeTooManyRequestsRetryHandler(): Closure
+ {
+ return static function (Exception $exception, PendingRequest $request): bool {
+ if (!($exception instanceof RequestException)) {
+ return false;
+ }
+
+ if ($exception->response->status() !== SymfonyResponse::HTTP_TOO_MANY_REQUESTS) {
+ return false;
+ }
+
+ $retryAfter = (int) $exception->response->header('Retry-After');
+
+ if (!$retryAfter) {
+ return false;
+ }
+
+ sleep($retryAfter);
+
+ return true;
+ };
+ }
+
+ protected function shouldPatchAsyncRequestExceptionHandling(): bool
+ {
+ return version_compare(app()->version(), '11.0.0') < 0;
+ }
+
+ /**
+ * @param (Response|GuzzleException)[] $responses
+ */
+ protected function patchAsyncRequestExceptionHandling(array $responses): void
+ {
+ foreach ($responses as $response) {
+ if ($response instanceof Throwable) {
+ throw $response;
+ }
+
+ $response->throw();
+ }
+ }
+}
diff --git a/src/Api/Objects/CursorPagination.php b/src/Api/Objects/CursorPagination.php
new file mode 100644
index 0000000..b25abfd
--- /dev/null
+++ b/src/Api/Objects/CursorPagination.php
@@ -0,0 +1,27 @@
+http($project, $branch)->get("/commands/{$branch->name}/pull", $query),
+ );
+ }
+
+ /**
+ * @param (callable(PushChunk $chunk): void)|null $onPushed
+ */
+ public function push(ProjectConfiguration $project, Branch $branch, PushBatch $batch, ?callable $onPushed = null, array $meta = []): void
+ {
+ Client::new()
+ ->withProject($project)
+ ->withBranch($branch)
+ ->pool($batch->pool, function (PendingRequest $request, PushChunk $chunk) use ($branch, $batch, $onPushed, $meta): void {
+ /** @var Promise $promise */
+ $promise = $request->post("/commands/{$branch->name}/push", [
+ 'batch' => [
+ 'id' => $batch->id,
+ 'max_pool_size' => PushBatch::maxPoolSize(),
+ 'max_chunk_size' => PushBatch::maxChunkSize(),
+ 'total_pushable' => $batch->totalPushable(),
+ 'total_pushed' => $batch->totalPushed(),
+ ],
+ 'chunk' => $chunk->toArray(),
+ 'meta' => $meta,
+ ]);
+
+ $promise->then(function (Response $response) use ($onPushed, $chunk): void {
+ if (!$response->successful()) {
+ return;
+ }
+
+ if ($onPushed) {
+ $onPushed($chunk);
+ }
+ });
+ });
+ }
+
+ public function pushEnd(ProjectConfiguration $project, Branch $branch, PushBatch $batch, array $meta = []): void
+ {
+ $this->http($project, $branch)->post("/commands/{$branch->name}/push/end", [
+ 'batch' => [
+ 'id' => $batch->id,
+ 'total_pushed' => $batch->totalPushed(),
+ ],
+ 'meta' => $meta,
+ ]);
+ }
+
+ public function initStart(ProjectConfiguration $project, Branch $branch, Branch $defaultBranch): void
+ {
+ $this->http($project, $branch)->post('/commands/init/start', [
+ 'locale' => [
+ 'default' => $project->options->locale->default,
+ 'fallback' => $project->options->locale->fallback,
+ ],
+ 'branching' => [
+ 'default_branch_name' => $defaultBranch->name,
+ ],
+ ]);
+ }
+
+ public function initEnd(ProjectConfiguration $project, Branch $branch): void
+ {
+ $this->http($project, $branch)->post('/commands/init/end');
+ }
+
+ protected function http(ProjectConfiguration $project, Branch $branch): PendingRequest
+ {
+ return Client::new()->withProject($project)->withBranch($branch)->http();
+ }
+}
diff --git a/src/Api/Requests/ReportRequests.php b/src/Api/Requests/ReportRequests.php
new file mode 100644
index 0000000..6b212bb
--- /dev/null
+++ b/src/Api/Requests/ReportRequests.php
@@ -0,0 +1,28 @@
+ $keys
+ */
+ public function missingTranslationKey(ProjectConfiguration $project, Branch $branch, array $keys): void
+ {
+ Client::new()
+ ->withProject($project)
+ ->withBranch($branch)
+ ->http()
+ ->post('/reports/missing-translation-keys', ['keys' => $keys]);
+ }
+}
diff --git a/src/Api/Resources/Translation/Line/Line.php b/src/Api/Resources/Translation/Line/Line.php
new file mode 100644
index 0000000..761925f
--- /dev/null
+++ b/src/Api/Resources/Translation/Line/Line.php
@@ -0,0 +1,31 @@
+ $lines
+ */
+ public readonly array $lines,
+ ) {
+ }
+
+ /**
+ * @param array{
+ * lines: array
+ * } $relations
+ */
+ public static function from(array $relations): static
+ {
+ return new static(
+ lines: array_map(static fn (array $line): Line => Line::from($line), $relations['lines']['data']),
+ );
+ }
+}
diff --git a/src/Api/Responses/Commands/PullResponse.php b/src/Api/Responses/Commands/PullResponse.php
new file mode 100644
index 0000000..a92ce8f
--- /dev/null
+++ b/src/Api/Responses/Commands/PullResponse.php
@@ -0,0 +1,38 @@
+ $data
+ */
+ public readonly array $data,
+ public readonly CursorPagination $pagination,
+ ) {
+ }
+
+ public static function fromClientResponse(Response $response): static
+ {
+ /** @var array $data */
+ $data = $response->json('data');
+
+ /** @var array $pagination */
+ $pagination = $response->json('pagination');
+
+ return new static(
+ data: array_map(static fn (array $set): Set => Set::from($set), $data),
+ pagination: CursorPagination::from($pagination),
+ );
+ }
+}
diff --git a/src/Commands/Concerns/CountsTranslationSet.php b/src/Commands/Concerns/CountsTranslationSet.php
new file mode 100644
index 0000000..ff54b3f
--- /dev/null
+++ b/src/Commands/Concerns/CountsTranslationSet.php
@@ -0,0 +1,25 @@
+acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces)
+ ->execute($this->project, $this->branch);
+ }
+}
diff --git a/src/Commands/Concerns/FiltersTranslationSet.php b/src/Commands/Concerns/FiltersTranslationSet.php
new file mode 100644
index 0000000..f4ac6ae
--- /dev/null
+++ b/src/Commands/Concerns/FiltersTranslationSet.php
@@ -0,0 +1,74 @@
+onlyLocales = $this->arrayableOptionValues($values);
+ }
+
+ protected function hydrateOnlyGroupsProperty(?array $values): void
+ {
+ $this->onlyGroups = $this->arrayableOptionValues($values);
+ }
+
+ protected function hydrateOnlyNamespacesProperty(?array $values): void
+ {
+ $this->onlyNamespaces = $this->arrayableOptionValues($values);
+ }
+
+ protected function hydrateExceptLocalesProperty(?array $values): void
+ {
+ $this->exceptLocales = $this->arrayableOptionValues($values);
+ }
+
+ protected function hydrateExceptGroupsProperty(?array $values): void
+ {
+ $this->exceptGroups = $this->arrayableOptionValues($values);
+ }
+
+ protected function hydrateExceptNamespacesProperty(?array $values): void
+ {
+ $this->exceptNamespaces = $this->arrayableOptionValues($values);
+ }
+}
diff --git a/src/Commands/Concerns/NeedsHelpers.php b/src/Commands/Concerns/NeedsHelpers.php
new file mode 100644
index 0000000..259692f
--- /dev/null
+++ b/src/Commands/Concerns/NeedsHelpers.php
@@ -0,0 +1,55 @@
+filter(static fn (mixed $value) => !blank($value))
+ ->flatMap(static fn (string $value) => explode(',', $value))
+ ->map(static fn (string $value) => trim($value))
+ ->filter(static fn (mixed $value) => !blank($value))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ /* Command option retrieval helpers
+ ------------------------------------------------*/
+
+ protected function optionAsNullableString(string $key): ?string
+ {
+ $value = $this->option($key);
+
+ if (is_null($value) || is_string($value)) {
+ return $value;
+ }
+
+ $this->throw("The given option `{$key}` is of an invalid type. Valid types are: `string`, `null`.");
+ }
+
+ protected function optionAsNullableArray(string $key): ?array
+ {
+ $value = $this->option($key);
+
+ if (is_null($value) || is_array($value)) {
+ return $value;
+ }
+
+ $this->throw("The given option `{$key}` is of an invalid type. Valid types are: `array`, `null`.");
+ }
+}
diff --git a/src/Commands/Concerns/OutputsRecapToConsole.php b/src/Commands/Concerns/OutputsRecapToConsole.php
new file mode 100644
index 0000000..40e5cf7
--- /dev/null
+++ b/src/Commands/Concerns/OutputsRecapToConsole.php
@@ -0,0 +1,122 @@
+beforeRecap();
+
+ $this->components->bulletList(
+ collect($this->buildRecapData($this->recapExtraData()))
+ ->filter()
+ ->map(static fn (string $value, string $key): string => "{$key}: {$value}")
+ ->all(),
+ );
+ }
+
+ protected function beforeRecap(): void
+ {
+ //
+ }
+
+ protected function recapExtraData(): array
+ {
+ return [];
+ }
+
+ protected function buildRecapData(array $extra = []): array
+ {
+ return [
+ 'Project' => $this->buildProjectRecapValue(),
+ 'Branch' => $this->buildBranchRecapValue(),
+ ...$extra,
+ 'Only locales' => $this->buildOnlyLocalesRecapValue(),
+ 'Only groups' => $this->buildOnlyGroupsRecapValue(),
+ 'Only namespaces' => $this->buildOnlyNamespacesRecapValue(),
+ 'Except locales' => $this->buildExceptLocalesRecapValue(),
+ 'Except groups' => $this->buildExceptGroupsRecapValue(),
+ 'Except namespaces' => $this->buildExceptNamespacesRecapValue(),
+ ];
+ }
+
+ protected function buildProjectRecapValue(): ?string
+ {
+ if (!property_exists($this, 'project')) {
+ return null;
+ }
+
+ return $this->project->label();
+ }
+
+ protected function buildBranchRecapValue(): ?string
+ {
+ if (!property_exists($this, 'branch')) {
+ return null;
+ }
+
+ $provenance = $this->branch->provenance();
+
+ return $this->branch->name . ($provenance ? " ({$provenance})" : '');
+ }
+
+ protected function buildOnlyLocalesRecapValue(): ?string
+ {
+ if (!property_exists($this, 'onlyLocales')) {
+ return null;
+ }
+
+ return implode(', ', $this->onlyLocales);
+ }
+
+ protected function buildOnlyGroupsRecapValue(): ?string
+ {
+ if (!property_exists($this, 'onlyGroups')) {
+ return null;
+ }
+
+ return implode(', ', $this->onlyGroups);
+ }
+
+ protected function buildOnlyNamespacesRecapValue(): ?string
+ {
+ if (!property_exists($this, 'onlyNamespaces')) {
+ return null;
+ }
+
+ return implode(', ', $this->onlyNamespaces);
+ }
+
+ protected function buildExceptLocalesRecapValue(): ?string
+ {
+ if (!property_exists($this, 'exceptLocales')) {
+ return null;
+ }
+
+ return implode(', ', $this->exceptLocales);
+ }
+
+ protected function buildExceptGroupsRecapValue(): ?string
+ {
+ if (!property_exists($this, 'exceptGroups')) {
+ return null;
+ }
+
+ return implode(', ', $this->exceptGroups);
+ }
+
+ protected function buildExceptNamespacesRecapValue(): ?string
+ {
+ if (!property_exists($this, 'exceptNamespaces')) {
+ return null;
+ }
+
+ return implode(', ', $this->exceptNamespaces);
+ }
+}
diff --git a/src/Commands/Concerns/UsesBranch.php b/src/Commands/Concerns/UsesBranch.php
new file mode 100644
index 0000000..b4397d5
--- /dev/null
+++ b/src/Commands/Concerns/UsesBranch.php
@@ -0,0 +1,44 @@
+name && $this->project->options->branching->mirror_current_branch) {
+ $value = Git::currentBranchName();
+ $branch = $value ? Branch::asCurrent(trim($value)) : null;
+ }
+
+ if (!$branch?->name) {
+ $value = $this->project->options->branching->default_branch_name ?: Git::defaultConfiguredBranchName();
+ $branch = $value ? Branch::asDefault(trim($value)) : null;
+ }
+
+ if (!$branch?->name) {
+ $branch = Branch::asFallback(Transl::FALLBACK_BRANCH_NAME);
+ }
+
+ $this->branch = $branch;
+ }
+}
diff --git a/src/Commands/Concerns/UsesConfig.php b/src/Commands/Concerns/UsesConfig.php
new file mode 100644
index 0000000..8f75ff8
--- /dev/null
+++ b/src/Commands/Concerns/UsesConfig.php
@@ -0,0 +1,27 @@
+config = Transl::config();
+ }
+}
diff --git a/src/Commands/Concerns/UsesProgressBar.php b/src/Commands/Concerns/UsesProgressBar.php
new file mode 100644
index 0000000..7a23b4e
--- /dev/null
+++ b/src/Commands/Concerns/UsesProgressBar.php
@@ -0,0 +1,44 @@
+output->createProgressBar();
+
+ $bar->setEmptyBarCharacter('░'); // light shade character \u2591
+ $bar->setProgressCharacter('');
+ $bar->setBarCharacter('▓'); // dark shade character \u2593
+
+ /**
+ * 10/100 [▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░] 10%.
+ */
+ // $bar->setFormat(ProgressBar::FORMAT_NORMAL);
+
+ /**
+ * 10/100 [▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░] 10% 5 secs.
+ */
+ // $bar->setFormat(ProgressBar::FORMAT_VERBOSE);
+
+ /**
+ * 10/100 [▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░] 10% 5 secs/5 mins, 33 secs.
+ */
+ $bar->setFormat(ProgressBar::FORMAT_VERY_VERBOSE);
+
+ /**
+ * 10/100 [▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░] 10% 5 secs/5 mins, 33 secs 28.0 MiB.
+ */
+ // $bar->setFormat(ProgressBar::FORMAT_DEBUG);
+
+ return $bar;
+ }
+}
diff --git a/src/Commands/Concerns/UsesProject.php b/src/Commands/Concerns/UsesProject.php
new file mode 100644
index 0000000..b0d3c20
--- /dev/null
+++ b/src/Commands/Concerns/UsesProject.php
@@ -0,0 +1,48 @@
+config->defaults()->project;
+ $projects = null;
+
+ if ($project) {
+ $projects = $this->config->projects()->whereAuthKeyOrName($project);
+ }
+
+ if (!$projects || $projects->isEmpty()) {
+ $this->throw(
+ "No target project could be determined. Please either provide the `--project=my_project_auth_key_or_name` option or the `transl.defaults.project` config value with the `auth_key` of an existing project on Transl.me.",
+ );
+ }
+
+ if ($projects->count() > 1) {
+ $this->throw(
+ "Found multiple projects with the same `auth_key` or `name`. Impossible to determine which to use.",
+ );
+ }
+
+ /** @var ProjectConfiguration $project */
+ $project = $projects->first();
+
+ $this->project = $project;
+ }
+}
diff --git a/src/Commands/TranslAnalyseCommand.php b/src/Commands/TranslAnalyseCommand.php
new file mode 100644
index 0000000..d9783b1
--- /dev/null
+++ b/src/Commands/TranslAnalyseCommand.php
@@ -0,0 +1,192 @@
+hydrateProperties();
+
+ $this->outputRecap();
+
+ $this->handler();
+
+ return $this->handled();
+ }
+
+ /* Hydration
+ ------------------------------------------------*/
+
+ protected function hydrateProperties(): void
+ {
+ $this->hydrateConfigProperty();
+ $this->hydrateProjectProperty($this->optionAsNullableString('project'));
+ $this->hydrateBranchProperty($this->optionAsNullableString('branch'));
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function drivers(): ProjectConfigurationDriverCollection
+ {
+ return $this->project->drivers;
+ }
+
+ protected function handler(): void
+ {
+ foreach ($this->drivers() as $driverClass => $driverParams) {
+ /** @var Driverable $driver */
+ $driver = app($driverClass, $driverParams);
+
+ $this->newLine();
+
+ $this->outputAnalysis(
+ $driver,
+ ProjectAnalysis::fromTranslationSets($driver->getTranslationSets($this->project, $this->branch)),
+ );
+ }
+ }
+
+ protected function handled(): int
+ {
+ $this->newLine(2);
+
+ return self::SUCCESS;
+ }
+
+ /* Console outputs
+ ------------------------------------------------*/
+
+ protected function outputAnalysis(Driverable $driver, ProjectAnalysis $analysis): void
+ {
+ $this->components->bulletList([
+ 'Using driver: ' . $driver::class,
+ ]);
+
+ $isVerbose = $this->getOutput()->isVerbose();
+ $isVeryVerbose = $this->getOutput()->isVeryVerbose();
+
+ $num = fn (int $value): string => $this->formatNumber($value);
+
+ foreach ($analysis->locales as $locale => $analysedLocale) {
+ $this->components->twoColumnDetail("• {$locale}>");
+
+ // Output Translation sets
+ if ($isVerbose) {
+ $this->components->twoColumnDetail("·· Translation sets>");
+
+ foreach ($analysedLocale->translation_sets as $translationSetKey => $analysedSet) {
+ $this->components->twoColumnDetail("···· {$translationSetKey}");
+
+ // Output Translation Lines
+ if ($isVeryVerbose) {
+ $this->components->twoColumnDetail("······ Lines>");
+
+ foreach ($analysedSet->lines as $translationLineKey => $analysedLine) {
+ $words = $num($analysedLine->word_count) . ($analysedLine->word_count > 1 ? ' words' : ' word');
+ $characters = $num($analysedLine->character_count) . ($analysedLine->character_count > 1 ? ' characters' : ' character');
+
+ $this->components->twoColumnDetail("········ {$translationLineKey}>", "{$words} / {$characters}>");
+ }
+
+ $this->components->twoColumnDetail("······ Summary>");
+ $this->components->twoColumnDetail("········ Translation lines", "{$num($analysedSet->summary->translation_line_count)}");
+ $this->components->twoColumnDetail("········ Translation line words", "{$num($analysedSet->summary->translation_line_word_count)}");
+ $this->components->twoColumnDetail("········ Translation line characters", "{$num($analysedSet->summary->translation_line_character_count)}");
+ } else {
+ $this->components->twoColumnDetail("······ Translation lines>", "{$num($analysedSet->summary->translation_line_count)}>");
+ $this->components->twoColumnDetail("······ Translation line words>", "{$num($analysedSet->summary->translation_line_word_count)}>");
+ $this->components->twoColumnDetail("······ Translation line characters>", "{$num($analysedSet->summary->translation_line_character_count)}>");
+ }
+ }
+
+ $this->components->twoColumnDetail("·· `{$locale}` summary>");
+ $this->components->twoColumnDetail("···· Translation sets", "{$num($analysedLocale->summary->translation_set_count)}");
+ $this->components->twoColumnDetail("···· Translation lines", "{$num($analysedLocale->summary->translation_line_count)}");
+ $this->components->twoColumnDetail("···· Translation line words", "{$num($analysedLocale->summary->translation_line_word_count)}");
+ $this->components->twoColumnDetail("···· Translation line characters", "{$num($analysedLocale->summary->translation_line_character_count)}");
+ } else {
+ $this->components->twoColumnDetail("·· Translation sets", "{$num($analysedLocale->summary->translation_set_count)}");
+ $this->components->twoColumnDetail("·· Translation lines", "{$num($analysedLocale->summary->translation_line_count)}");
+ $this->components->twoColumnDetail("·· Translation line words", "{$num($analysedLocale->summary->translation_line_word_count)}");
+ $this->components->twoColumnDetail("·· Translation line characters", "{$num($analysedLocale->summary->translation_line_character_count)}");
+ }
+ }
+
+ $this->components->twoColumnDetail("• Project summary>");
+ $this->components->twoColumnDetail("·· Unique translation keys", "{$num($analysis->summary->unique_translation_key_count)}");
+ $this->components->twoColumnDetail("·· Unique translation sets", "{$num($analysis->summary->unique_translation_set_count)}");
+ $this->components->twoColumnDetail("·· Translation keys", "{$num($analysis->summary->translation_key_count)}");
+ $this->components->twoColumnDetail("·· Translation sets", "{$num($analysis->summary->translation_set_count)}");
+ $this->components->twoColumnDetail("·· Translation lines", "{$num($analysis->summary->translation_line_count)}");
+ $this->components->twoColumnDetail("·· Translation line words", "{$num($analysis->summary->translation_line_word_count)}");
+ $this->components->twoColumnDetail("·· Translation line characters", "{$num($analysis->summary->translation_line_character_count)}");
+ }
+
+ /* Helpers
+ ------------------------------------------------*/
+
+ protected function formatNumber(int $value): string
+ {
+ if (!extension_loaded('intl')) {
+ return (string) $value;
+ }
+
+ $formatted = class_exists(Number::class)
+ ? Number::format($value, locale: app()->getLocale())
+ : (function () use ($value): string|bool {
+ $formatter = new NumberFormatter(app()->getLocale(), NumberFormatter::DECIMAL);
+
+ return $formatter->format($value);
+ })();
+
+ if (is_bool($formatted)) {
+ return (string) $value;
+ }
+
+ return str($formatted)->ascii()->value();
+ }
+}
diff --git a/src/Commands/TranslInitCommand.php b/src/Commands/TranslInitCommand.php
new file mode 100644
index 0000000..3e6dba2
--- /dev/null
+++ b/src/Commands/TranslInitCommand.php
@@ -0,0 +1,141 @@
+hydrateProperties();
+
+ $this->outputRecap();
+
+ $this->newLine();
+
+ $bar = $this->createProgressBar();
+
+ $bar->start();
+
+ $this->handler($bar);
+
+ $bar->finish();
+
+ return $this->handled();
+ }
+
+ /* Hydration
+ ------------------------------------------------*/
+
+ protected function hydrateProperties(): void
+ {
+ $this->hydrateConfigProperty();
+ $this->hydrateProjectProperty($this->optionAsNullableString('project'));
+ $this->hydrateBranchProperty($this->optionAsNullableString('branch'));
+ $this->hydrateOnlyLocalesProperty($this->optionAsNullableArray('only-locales'));
+ $this->hydrateOnlyGroupsProperty($this->optionAsNullableArray('only-groups'));
+ $this->hydrateOnlyNamespacesProperty($this->optionAsNullableArray('only-namespaces'));
+ $this->hydrateExceptLocalesProperty($this->optionAsNullableArray('except-locales'));
+ $this->hydrateExceptGroupsProperty($this->optionAsNullableArray('except-groups'));
+ $this->hydrateExceptNamespacesProperty($this->optionAsNullableArray('except-namespaces'));
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function handler(ProgressBar $bar): void
+ {
+ $count = $this->count();
+
+ $batch = PushBatch::new($count);
+
+ $bar->setMaxSteps($count);
+
+ $this
+ ->actionCommand()
+ ->acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces)
+ ->onTranslationSetHandled(static fn () => $bar->advance())
+ ->execute($this->project, $this->branch, $batch);
+ }
+
+ protected function handled(): int
+ {
+ $this->newLine(2);
+
+ $this->components->info("The project `{$this->project->name}` was initialized successfully!");
+
+ return self::SUCCESS;
+ }
+
+ protected function actionCommand(): InitCommandAction
+ {
+ return Transl::commands()->init();
+ }
+
+ /* Console outputs
+ ------------------------------------------------*/
+
+ protected function beforeRecap(): void
+ {
+ $this->components->info("Initializing:");
+ }
+}
diff --git a/src/Commands/TranslPullCommand.php b/src/Commands/TranslPullCommand.php
new file mode 100644
index 0000000..3af872d
--- /dev/null
+++ b/src/Commands/TranslPullCommand.php
@@ -0,0 +1,286 @@
+hydrateProperties();
+
+ $this->outputRecap();
+
+ $this->newLine();
+
+ $bar = $this->createProgressBar();
+
+ $bar->start();
+
+ $this->handler($bar);
+
+ $bar->finish();
+
+ return $this->handled();
+ }
+
+ /* Hydration
+ ------------------------------------------------*/
+
+ protected function hydrateProperties(): void
+ {
+ $this->hydrateConfigProperty();
+ $this->hydrateProjectProperty($this->optionAsNullableString('project'));
+ $this->hydrateBranchProperty($this->optionAsNullableString('branch'));
+ $this->hydrateConflictsProperty($this->optionAsNullableString('conflicts'));
+ $this->hydrateOnlyLocalesProperty($this->optionAsNullableArray('only-locales'));
+ $this->hydrateOnlyGroupsProperty($this->optionAsNullableArray('only-groups'));
+ $this->hydrateOnlyNamespacesProperty($this->optionAsNullableArray('only-namespaces'));
+ $this->hydrateExceptLocalesProperty($this->optionAsNullableArray('except-locales'));
+ $this->hydrateExceptGroupsProperty($this->optionAsNullableArray('except-groups'));
+ $this->hydrateExceptNamespacesProperty($this->optionAsNullableArray('except-namespaces'));
+ }
+
+ protected function hydrateConflictsProperty(?string $value): void
+ {
+ $value = $value ? trim($value) : null;
+
+ if ($value) {
+ $value = BranchingConflictResolutionEnum::from($value);
+ }
+
+ if (!$value) {
+ $value = $this->project->options->branching->conflict_resolution;
+ }
+
+ $this->conflicts = $value;
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function handler(ProgressBar $bar): void
+ {
+ $this
+ ->actionCommand()
+ ->acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces)
+ ->silenceConflictExceptions()
+ ->onTranslationSetHandled(static fn () => $bar->advance())
+ ->onIncomingTranslationSetConflicts(
+ fn (TranslationSet $set, TranslationLinesDiffing $diff) => $this->collectConflictsData($set, $diff),
+ )
+ ->execute($this->project, $this->branch, $this->conflicts);
+ }
+
+ protected function handled(): int
+ {
+ $this->newLine(2);
+
+ $this->outputConflictsAwareSummaryText();
+ $this->outputConflictsData();
+
+ if (!empty($this->conflictsData) && $this->conflicts === BranchingConflictResolutionEnum::THROW) {
+ return self::FAILURE;
+ }
+
+ if (!empty($this->conflictsData) && $this->conflicts === BranchingConflictResolutionEnum::MERGE_BUT_THROW) {
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+
+ protected function actionCommand(): PullCommandAction
+ {
+ return Transl::commands()->pull();
+ }
+
+ protected function collectConflictsData(TranslationSet $incoming, TranslationLinesDiffing $diff): void
+ {
+ if ($diff->conflictingLines()->isEmpty()) {
+ return;
+ }
+
+ $updatedLineKeys = array_keys($diff->updatedLines()->toRawTranslationLines());
+ $addedLineKeys = array_keys($diff->addedLines()->toRawTranslationLines());
+ $removedLineKeys = array_keys($diff->removedLines()->toRawTranslationLines());
+ $conflictingLineKeys = array_keys($diff->conflictingLines()->toRawTranslationLines());
+
+ $dataKey = $incoming->translationKey() . ' · ' . $incoming->locale; // •·
+ $currentData = $this->conflictsData[$dataKey] ?? [];
+ $data = [
+ 'updated' => array_intersect_key($updatedLineKeys, $conflictingLineKeys),
+ 'added' => array_intersect_key($addedLineKeys, $conflictingLineKeys),
+ 'removed' => array_intersect_key($removedLineKeys, $conflictingLineKeys),
+ ];
+
+ sort($data['updated']);
+ sort($data['added']);
+ sort($data['removed']);
+
+ $this->conflictsData = [
+ ...$this->conflictsData,
+ $dataKey => [
+ ...$currentData,
+ 'updated' => [
+ ...($currentData['updated'] ?? []),
+ ...$data['updated'],
+ ],
+ 'added' => [
+ ...($currentData['added'] ?? []),
+ ...$data['added'],
+ ],
+ 'removed' => [
+ ...($currentData['removed'] ?? []),
+ ...$data['removed'],
+ ],
+ ],
+ ];
+ }
+
+ /* Console outputs
+ ------------------------------------------------*/
+
+ protected function beforeRecap(): void
+ {
+ $this->components->info("Pulling translation lines for:");
+ }
+
+ protected function recapExtraData(): array
+ {
+ return ['Conflict resolution' => $this->conflicts->value];
+ }
+
+ protected function outputConflictsAwareSummaryText(): void
+ {
+ if (empty($this->conflictsData)) {
+ $this->components->info("The translation lines of the project `{$this->project->name}` were pulled successfully!");
+ }
+
+ if (!empty($this->conflictsData) && $this->conflicts === BranchingConflictResolutionEnum::IGNORE) {
+ $this->components->warn("The translation lines of the project `{$this->project->name}` were pulled with conflicts:");
+ }
+
+ if (!empty($this->conflictsData) && $this->conflicts === BranchingConflictResolutionEnum::MERGE_AND_IGNORE) {
+ $this->components->warn("The translation lines of the project `{$this->project->name}` were pulled with conflicts:");
+ }
+
+ if (!empty($this->conflictsData) && $this->conflicts === BranchingConflictResolutionEnum::THROW) {
+ $this->components->error("The translation lines of the project `{$this->project->name}` were pulled with conflicts:");
+ }
+
+ if (!empty($this->conflictsData) && $this->conflicts === BranchingConflictResolutionEnum::MERGE_BUT_THROW) {
+ $this->components->error("The translation lines of the project `{$this->project->name}` were pulled with conflicts:");
+ }
+ }
+
+ protected function outputConflictsData(): void
+ {
+ if (empty($this->conflictsData)) {
+ return;
+ }
+
+ collect($this->conflictsData)
+ ->sortBy(static function (array $data, string $section): int {
+ if ($section === '') {
+ return PHP_INT_MAX;
+ }
+
+ return count($data['added']) + count($data['updated']) + count($data['removed']);
+ })
+ ->each(function (array $data, string $section): void {
+ $this->newLine();
+
+ if ($section === '') {
+ $section = 'UNGROUPED';
+ }
+
+ $conflictsCount = count($data['added']) + count($data['updated']) + count($data['removed']);
+ $conflictsText = $conflictsCount > 1 ? 'conflicts' : 'conflict';
+
+ $this->components->twoColumnDetail(" {$section} ({$conflictsCount} {$conflictsText})>");
+
+ foreach ($data['added'] as $key) {
+ $this->components->twoColumnDetail($key, ' added>');
+ }
+
+ foreach ($data['updated'] as $key) {
+ $this->components->twoColumnDetail($key, ' updated>');
+ }
+
+ foreach ($data['removed'] as $key) {
+ $this->components->twoColumnDetail($key, ' removed>');
+ }
+ });
+ }
+}
diff --git a/src/Commands/TranslPushCommand.php b/src/Commands/TranslPushCommand.php
new file mode 100644
index 0000000..9af7c37
--- /dev/null
+++ b/src/Commands/TranslPushCommand.php
@@ -0,0 +1,152 @@
+hydrateProperties();
+
+ $this->outputRecap();
+
+ $this->newLine();
+
+ $bar = $this->createProgressBar();
+
+ $bar->start();
+
+ $this->handler($bar);
+
+ $bar->finish();
+
+ return $this->handled();
+ }
+
+ /* Hydration
+ ------------------------------------------------*/
+
+ protected function hydrateProperties(): void
+ {
+ $this->hydrateConfigProperty();
+ $this->hydrateProjectProperty($this->optionAsNullableString('project'));
+ $this->hydrateBranchProperty($this->optionAsNullableString('branch'));
+ $this->hydrateOnlyLocalesProperty($this->optionAsNullableArray('only-locales'));
+ $this->hydrateOnlyGroupsProperty($this->optionAsNullableArray('only-groups'));
+ $this->hydrateOnlyNamespacesProperty($this->optionAsNullableArray('only-namespaces'));
+ $this->hydrateExceptLocalesProperty($this->optionAsNullableArray('except-locales'));
+ $this->hydrateExceptGroupsProperty($this->optionAsNullableArray('except-groups'));
+ $this->hydrateExceptNamespacesProperty($this->optionAsNullableArray('except-namespaces'));
+
+ $this->message = $this->optionAsNullableString('message');
+ }
+
+ /* Actions
+ ------------------------------------------------*/
+
+ protected function handler(ProgressBar $bar): void
+ {
+ $count = $this->count();
+
+ $batch = PushBatch::new($count);
+
+ $bar->setMaxSteps($count);
+
+ $this
+ ->actionCommand()
+ ->withMessage($this->message)
+ ->acceptsLocales($this->onlyLocales)
+ ->acceptsGroups($this->onlyGroups)
+ ->acceptsNamespaces($this->onlyNamespaces)
+ ->rejectsLocales($this->exceptLocales)
+ ->rejectsGroups($this->exceptGroups)
+ ->rejectsNamespaces($this->exceptNamespaces)
+ ->onTranslationSetHandled(static fn () => $bar->advance())
+ ->execute($this->project, $this->branch, $batch);
+ }
+
+ protected function handled(): int
+ {
+ $this->newLine(2);
+
+ $this->components->info("The translation lines of the project `{$this->project->name}` were pushed successfully!");
+
+ return self::SUCCESS;
+ }
+
+ protected function actionCommand(): PushCommandAction
+ {
+ return Transl::commands()->push();
+ }
+
+ /* Console outputs
+ ------------------------------------------------*/
+
+ protected function beforeRecap(): void
+ {
+ $this->components->info("Pushing translation lines for:");
+ }
+}
diff --git a/src/Commands/TranslSynchCommand.php b/src/Commands/TranslSynchCommand.php
new file mode 100644
index 0000000..03b4e8e
--- /dev/null
+++ b/src/Commands/TranslSynchCommand.php
@@ -0,0 +1,87 @@
+providedOptions();
+
+ $pulled = $this->call(TranslPullCommand::class, $options);
+
+ $this->newLine(3);
+
+ if ($pulled !== self::SUCCESS) {
+ $this->outputFailureMessage();
+
+ return self::FAILURE;
+ }
+
+ $pushed = $this->call(TranslPushCommand::class, Arr::except($options, ['--conflicts']));
+
+ $this->newLine(3);
+
+ if ($pushed !== self::SUCCESS) {
+ $this->outputFailureMessage();
+
+ return self::FAILURE;
+ }
+
+ $this->components->info('Translation lines synchronized successfully!');
+
+ return self::SUCCESS;
+ }
+
+ protected function providedOptions(): array
+ {
+ return collect($this->options())->reduce(static function (array $acc, mixed $value, string $key) {
+ $acc["--{$key}"] = $value;
+
+ return $acc;
+ }, []);
+ }
+
+ protected function outputFailureMessage(): void
+ {
+ $this->components->error('Failed synchronizing the translation lines.');
+ }
+}
diff --git a/src/Config/Configuration.php b/src/Config/Configuration.php
new file mode 100644
index 0000000..49c0333
--- /dev/null
+++ b/src/Config/Configuration.php
@@ -0,0 +1,84 @@
+
+ * @phpstan-consistent-constructor
+ */
+class Configuration implements Arrayable
+{
+ use Instanciable;
+
+ protected ReportingConfiguration $reporting;
+ protected DefaultConfiguration $defaults;
+ protected ProjectConfigurationCollection $projects;
+
+ public function __construct(array $config)
+ {
+ $this->hydrateProperties($config);
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(array $config): static
+ {
+ return new static($config);
+ }
+
+ /**
+ * The reporting configurations.
+ */
+ public function reporting(): ReportingConfiguration
+ {
+ return $this->reporting;
+ }
+
+ /**
+ * The defaults configurations.
+ */
+ public function defaults(): DefaultConfiguration
+ {
+ return $this->defaults;
+ }
+
+ /**
+ * The projects configurations.
+ */
+ public function projects(): ProjectConfigurationCollection
+ {
+ return $this->projects;
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'reporting' => $this->reporting()->toArray(),
+ 'defaults' => $this->defaults()->toArray(),
+ 'projects' => $this->projects()->toArray(),
+ ];
+ }
+
+ protected function hydrateProperties(array $config): void
+ {
+ $projects = $config['projects'] ?? [];
+
+ $this->reporting = ReportingConfiguration::new($config['reporting'] ?? [], ReportingConfigurationValues::new());
+ $this->defaults = DefaultConfiguration::new($config['defaults'] ?? [], DefaultConfigurationValues::new($projects));
+ $this->projects = ProjectConfigurationCollection::makeWithDefaults($projects, $this->defaults);
+ }
+}
diff --git a/src/Config/DefaultConfiguration.php b/src/Config/DefaultConfiguration.php
new file mode 100644
index 0000000..43eb89c
--- /dev/null
+++ b/src/Config/DefaultConfiguration.php
@@ -0,0 +1,78 @@
+
+ * @phpstan-consistent-constructor
+ */
+class DefaultConfiguration implements Arrayable
+{
+ /**
+ * The default project to be considered in contexts
+ * where a project is needed but none where explicitly
+ * provided.
+ */
+ public readonly ?string $project;
+
+ /**
+ * Default project options that will be used in filling
+ * a given project's option that hasn't been given a value.
+ * In other words, fallback option values for a given project.
+ *
+ * The exact same as `project.options`.
+ */
+ public readonly ProjectConfigurationOptions $project_options;
+
+ public function __construct(array $values, DefaultConfigurationValues $defaults)
+ {
+ $this->hydrateProperties($values, $defaults);
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(array $values, DefaultConfigurationValues $defaults): static
+ {
+ return new static($values, $defaults);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'project' => $this->project,
+ 'project_options' => $this->project_options->toArray(),
+ ];
+ }
+
+ protected function hydrateProperties(array $values, DefaultConfigurationValues $defaults): void
+ {
+ $this->hydrateProjectProperty($values, $defaults);
+ $this->hydrateProjectOptionsProperty($values, $defaults);
+ }
+
+ protected function hydrateProjectProperty(array $values, DefaultConfigurationValues $defaults): void
+ {
+ // @phpstan-ignore-next-line
+ $this->project = $values['project'] ?? $defaults->project;
+ }
+
+ protected function hydrateProjectOptionsProperty(array $values, DefaultConfigurationValues $defaults): void
+ {
+ // @phpstan-ignore-next-line
+ $this->project_options = MergeProjectConfigurationOptions::mergeWithDefaults(
+ $values['project_options'] ?? [],
+ $defaults->project_options,
+ );
+ }
+}
diff --git a/src/Config/Defaults/DefaultConfigurationValues.php b/src/Config/Defaults/DefaultConfigurationValues.php
new file mode 100644
index 0000000..13cb19d
--- /dev/null
+++ b/src/Config/Defaults/DefaultConfigurationValues.php
@@ -0,0 +1,89 @@
+
+ * @phpstan-consistent-constructor
+ */
+class DefaultConfigurationValues implements Arrayable
+{
+ public readonly ?string $project;
+ public readonly ProjectConfigurationOptions $project_options;
+
+ public function __construct(array $projects = [])
+ {
+ $this->project = $this->determineDefaultProjectAuthKey($projects);
+ $this->project_options = ProjectConfigurationOptions::new(
+ transl_directory: $this->makeDefaultProjectConfigurationTranslDirectory(),
+ locale: $this->makeDefaultProjectConfigurationLocale(),
+ branching: $this->makeDefaultProjectConfigurationBranching(),
+ );
+ }
+
+ public static function new(array $projects = []): static
+ {
+ return new static($projects);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'project' => $this->project,
+ 'project_options' => $this->project_options->toArray(),
+ ];
+ }
+
+ protected function determineDefaultProjectAuthKey(array $projects): ?string
+ {
+ if (count($projects) !== 1) {
+ return null;
+ }
+
+ /** @var array $project */
+ $project = head($projects);
+
+ return $project['auth_key'] ?? null;
+ }
+
+ protected function makeDefaultProjectConfigurationTranslDirectory(): ?string
+ {
+ return storage_path('app/.transl');
+ }
+
+ protected function makeDefaultProjectConfigurationLocale(): ProjectConfigurationLocale
+ {
+ /** @var string|null $locale */
+ $locale = config('app.locale');
+
+ /** @var string|null $fallback */
+ $fallback = config('app.fallback_locale');
+
+ return ProjectConfigurationLocale::new(
+ default: $locale,
+ fallback: $fallback,
+ allowed: null,
+ throw_on_disallowed_locale: true,
+ );
+ }
+
+ protected function makeDefaultProjectConfigurationBranching(): ProjectConfigurationBranching
+ {
+ return ProjectConfigurationBranching::new(
+ default_branch_name: null,
+ mirror_current_branch: true,
+ conflict_resolution: BranchingConflictResolutionEnum::MERGE_BUT_THROW,
+ );
+ }
+}
diff --git a/src/Config/Defaults/ReportingConfigurationValues.php b/src/Config/Defaults/ReportingConfigurationValues.php
new file mode 100644
index 0000000..1245902
--- /dev/null
+++ b/src/Config/Defaults/ReportingConfigurationValues.php
@@ -0,0 +1,43 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ReportingConfigurationValues implements Arrayable
+{
+ public readonly bool $should_report_missing_translation_keys;
+ public readonly string $report_missing_translation_keys_using;
+ public readonly bool $silently_discard_exceptions;
+
+ public function __construct()
+ {
+ $this->should_report_missing_translation_keys = app()->isProduction();
+ $this->report_missing_translation_keys_using = ReportMissingTranslationKeysAction::class;
+ $this->silently_discard_exceptions = app()->isProduction();
+ }
+
+ public static function new(): static
+ {
+ return new static();
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'should_report_missing_translation_keys' => $this->should_report_missing_translation_keys,
+ 'report_missing_translation_keys_using' => $this->report_missing_translation_keys_using,
+ 'silently_discard_exceptions' => $this->silently_discard_exceptions,
+ ];
+ }
+}
diff --git a/src/Config/Enums/BranchingConflictResolutionEnum.php b/src/Config/Enums/BranchingConflictResolutionEnum.php
new file mode 100644
index 0000000..df375ab
--- /dev/null
+++ b/src/Config/Enums/BranchingConflictResolutionEnum.php
@@ -0,0 +1,60 @@
+ static::determineTranslDirectory($options, $defaults),
+ 'locale' => [
+ 'default' => $options['locale']['default'] ?? $defaults->locale->default,
+ 'fallback' => $options['locale']['fallback'] ?? $defaults->locale->fallback,
+ 'allowed' => $options['locale']['allowed'] ?? $defaults->locale->allowed,
+ 'throw_on_disallowed_locale' => $options['locale']['throw_on_disallowed_locale'] ?? $defaults->locale->throw_on_disallowed_locale,
+ ],
+ 'branching' => [
+ 'default_branch_name' => $options['branching']['default_branch_name'] ?? $defaults->branching->default_branch_name,
+ 'mirror_current_branch' => $options['branching']['mirror_current_branch'] ?? $defaults->branching->mirror_current_branch,
+ 'conflict_resolution' => $options['branching']['conflict_resolution'] ?? $defaults->branching->conflict_resolution,
+ ],
+ ];
+
+ return ProjectConfigurationOptions::new(...$options);
+ }
+
+ protected static function determineTranslDirectory(array $options, ProjectConfigurationOptions $defaults): ?string
+ {
+ $value = $options['transl_directory'] ?? null;
+
+ if ($value === false) {
+ return null;
+ }
+
+ if (is_string($value)) {
+ return $value;
+ }
+
+ return $defaults->transl_directory;
+ }
+}
diff --git a/src/Config/ProjectConfiguration.php b/src/Config/ProjectConfiguration.php
new file mode 100644
index 0000000..aa376e2
--- /dev/null
+++ b/src/Config/ProjectConfiguration.php
@@ -0,0 +1,124 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ProjectConfiguration implements Arrayable
+{
+ public function __construct(
+ /**
+ * The project's authentication key.
+ * Used to both identify the project and
+ * the user making local and remote changes.
+ */
+ public readonly string $auth_key,
+
+ /**
+ * An arbitrary unique value given to identify
+ * the project | A user friendly name given to the project.
+ *
+ * Used when printing the project back to the user
+ * in console outputs, exception messages etc... .
+ *
+ * Falls back to be a truncated and redacted version
+ * of the authentication key.
+ */
+ public readonly string $name,
+
+ /**
+ * The project's configuration options.
+ * Used to configure behaviors.
+ */
+ public readonly ProjectConfigurationOptions $options,
+
+ /**
+ * The project's configuration drivers.
+ * Used for identifying, retrieving, updating and handling
+ * translation contents.
+ */
+ public readonly ProjectConfigurationDriverCollection $drivers,
+ ) {
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(
+ string $auth_key,
+ string $name,
+ ProjectConfigurationOptions $options,
+ ProjectConfigurationDriverCollection $drivers,
+ ): static {
+ return new static($auth_key, $name, $options, $drivers);
+ }
+
+ /**
+ * Named constructor accepting an arbitrary array of values and
+ * defaults that will be used to contructor a new instance.
+ */
+ public static function make(array $item, ?DefaultConfiguration $defaults = null): static
+ {
+ return $defaults ? static::makeWithDefaults($item, $defaults) : static::new(...$item);
+ }
+
+ protected static function makeWithDefaults(array $item, DefaultConfiguration $defaults): static
+ {
+ $item['name'] = $item['name'] ?? static::redactAuthKey($item['auth_key']);
+
+ $item['options'] = MergeProjectConfigurationOptions::mergeWithDefaults(
+ $item['options'] ?? [],
+ $defaults->project_options,
+ );
+
+ /** @var array|int, array|null> $item['drivers'] */
+ $item['drivers'] = ProjectConfigurationDriverCollection::make($item['drivers'] ?? []);
+
+ return static::make($item);
+ }
+
+ protected static function redactAuthKey(string $value): string
+ {
+ $prefixLength = mb_strpos($value, '_') + 1;
+
+ return mb_substr($value, 0, $prefixLength + 5) . '•••••' . mb_substr($value, -3);
+ }
+
+ /**
+ * The project's label.
+ * Used in contexts where the project is printed
+ * out to users.
+ */
+ public function label(): string
+ {
+ if (str_contains($this->name, '•')) {
+ return $this->name;
+ }
+
+ return $this->name . ' (' . static::redactAuthKey($this->auth_key) . ')';
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'auth_key' => $this->auth_key,
+ 'name' => $this->name,
+ 'options' => $this->options->toArray(),
+ 'drivers' => $this->drivers->toArray(),
+ ];
+ }
+}
diff --git a/src/Config/ProjectConfigurationCollection.php b/src/Config/ProjectConfigurationCollection.php
new file mode 100644
index 0000000..c6859ad
--- /dev/null
+++ b/src/Config/ProjectConfigurationCollection.php
@@ -0,0 +1,162 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ProjectConfigurationCollection extends Collection
+{
+ /**
+ * Create a new collection.
+ *
+ * @param Arrayable|iterable|null $items
+ * @return void
+ */
+ public function __construct($items = [])
+ {
+ $this->items = $this->arrayableItemsToProjectConfigurations($this->getArrayableItems($items));
+ }
+
+ /**
+ * Create a new collection instance with defaults
+ * if the value isn't one already.
+ *
+ * @param iterable $items
+ */
+ public static function makeWithDefaults(iterable $items, DefaultConfiguration $defaults): static
+ {
+ $items = collect($items)
+ ->map(static fn (array $project): ProjectConfiguration => ProjectConfiguration::make($project, $defaults))
+ ->all();
+
+ return new static($items);
+ }
+
+ /**
+ * Find `ProjectConfiguration`s by a given "auth_key".
+ */
+ public function whereAuthKey(string $authKey): static
+ {
+ return $this->where('auth_key', $authKey);
+ }
+
+ /**
+ * Find `ProjectConfiguration`s by a given "name".
+ */
+ public function whereName(string $name): static
+ {
+ return $this->where('name', $name);
+ }
+
+ /**
+ * Find `ProjectConfiguration`s by a given "auth_key" or "name".
+ */
+ public function whereAuthKeyOrName(string $key): static
+ {
+ $byAuthKey = $this->whereAuthKey($key);
+
+ if ($byAuthKey->isNotEmpty()) {
+ return $byAuthKey;
+ }
+
+ return $this->whereName($key);
+ }
+
+ // /**
+ // * Find the first possible `ProjectConfiguration` by it's "auth_key".
+ // */
+ // public function firstWhereAuthKey(string $authKey): ?ProjectConfiguration
+ // {
+ // return $this->firstWhere('auth_key', $authKey);
+ // }
+
+ // /**
+ // * Find the first possible `ProjectConfiguration` by it's "name".
+ // */
+ // public function firstWhereName(string $name): ?ProjectConfiguration
+ // {
+ // return $this->firstWhere('name', $name);
+ // }
+
+ // /**
+ // * Find the first possible `ProjectConfiguration` by it's "auth_key"
+ // * or throw an exception.
+ // */
+ // public function firstByAuthKeyOrFail(string $authKey): ProjectConfiguration
+ // {
+ // return $this->firstOrFail('auth_key', $authKey);
+ // }
+
+ // /**
+ // * Find the first possible `ProjectConfiguration` by it's "name"
+ // * or throw an exception.
+ // */
+ // public function firstByNameOrFail(string $name): ProjectConfiguration
+ // {
+ // return $this->firstOrFail('name', $name);
+ // }
+
+ // /**
+ // * Find a `ProjectConfiguration` by it's "auth_key" and
+ // * ensure only one exists or throw an exception.
+ // */
+ // public function soleByAuthKey(string $authKey): ProjectConfiguration
+ // {
+ // return $this->sole('auth_key', $authKey);
+ // }
+
+ // /**
+ // * Find a `ProjectConfiguration` by it's "name" and
+ // * ensure only one exists or throw an exception.
+ // */
+ // public function soleByName(string $name): ProjectConfiguration
+ // {
+ // return $this->sole('name', $name);
+ // }
+
+ /**
+ * Get the collection of items as a plain array.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return array_map(static fn (ProjectConfiguration $project): array => $project->toArray(), $this->items);
+ }
+
+ /**
+ * Converts items into ProjectConfigurations.
+ *
+ * @param array $items
+ * @return array
+ */
+ protected function arrayableItemsToProjectConfigurations(array $items): array
+ {
+ return array_reduce($items, function (array $acc, array|ProjectConfiguration $item): array {
+ if (!($item instanceof ProjectConfiguration)) {
+ $item = $this->arrayableItemToProjectConfiguration($item);
+ }
+
+ $acc[] = $item;
+
+ return $acc;
+ }, []);
+ }
+
+ /**
+ * Converts an item into a ProjectConfiguration.
+ */
+ protected function arrayableItemToProjectConfiguration(array $item): ProjectConfiguration
+ {
+ return ProjectConfiguration::make($item);
+ }
+}
diff --git a/src/Config/ProjectConfigurationDriverCollection.php b/src/Config/ProjectConfigurationDriverCollection.php
new file mode 100644
index 0000000..a3fe9db
--- /dev/null
+++ b/src/Config/ProjectConfigurationDriverCollection.php
@@ -0,0 +1,85 @@
+, array>
+ */
+class ProjectConfigurationDriverCollection extends Collection
+{
+ /**
+ * Create a new collection.
+ *
+ * @param Arrayable, array>|iterable, array>|null $items
+ * @return void
+ */
+ public function __construct($items = [])
+ {
+ $this->items = $this->arrayableItemsToStanderdizedItems($this->getArrayableItems($items));
+ }
+
+ /**
+ * Converts items into ProjectConfigurations.
+ *
+ * @param array $items
+ * @return array, array>
+ */
+ protected function arrayableItemsToStanderdizedItems(array $items): array
+ {
+ return collect($items)->reduce(function (array $acc, string|array $value, string|int $key): array {
+ /** @var string|null $class */
+ $class = $this->arrayableItemKeyCanBeUseAsAClassString($key) ? $key : null;
+ $parameters = $this->arrayableItemValueCanBeUsedAsClassParameters($value) ? $value : [];
+
+ if (!$class && $this->arrayableItemValueCanBeUseAsAClassString($value)) {
+ /** @var string $class */
+ $class = $value;
+ }
+
+ $this->ensureArrayableItemDriverIsValid($class);
+
+ $acc[$class] = $parameters;
+
+ return $acc;
+ }, []);
+ }
+
+ protected function arrayableItemKeyCanBeUseAsAClassString(string|int $key): bool
+ {
+ return is_string($key);
+ }
+
+ protected function arrayableItemValueCanBeUseAsAClassString(string|array $value): bool
+ {
+ return is_string($value);
+ }
+
+ protected function arrayableItemValueCanBeUsedAsClassParameters(string|array $value): bool
+ {
+ return is_array($value);
+ }
+
+ protected function ensureArrayableItemDriverIsValid(null|string $class): void
+ {
+ if (is_null($class)) {
+ throw ProjectConfigurationDriverInvalidPhpClass::make();
+ }
+
+ if (!class_exists($class)) {
+ throw ProjectConfigurationDriverClassDoesntExist::make($class);
+ }
+
+ if (!in_array(Driverable::class, class_implements($class) ?: [], true)) {
+ throw ProjectConfigurationDriverDoesntImplementCorrectInterface::make($class);
+ }
+ }
+}
diff --git a/src/Config/ReportingConfiguration.php b/src/Config/ReportingConfiguration.php
new file mode 100644
index 0000000..eee1dc5
--- /dev/null
+++ b/src/Config/ReportingConfiguration.php
@@ -0,0 +1,81 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ReportingConfiguration implements Arrayable
+{
+ /**
+ * Whether translation keys used but for which
+ * no corresponding translation value could be
+ * found should be reported to Transl.
+ *
+ * Ex.: `__('nonexistent')` -> reports "nonexistent".
+ */
+ public readonly bool $should_report_missing_translation_keys;
+
+ /**
+ * The class that should be used to report missing translation
+ * keys. The class should have either an `__invokable` or
+ * `execute` method.
+ */
+ public readonly string $report_missing_translation_keys_using;
+
+ /**
+ * Whether exceptions thrown during the catching and reporting
+ * process should be silently discarded. Probably best to
+ * enable this in production environnements.
+ */
+ public readonly bool $silently_discard_exceptions;
+
+ public function __construct(array $values, ReportingConfigurationValues $defaults)
+ {
+ $this->hydrateProperties($values, $defaults);
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(array $values, ReportingConfigurationValues $defaults): static
+ {
+ return new static($values, $defaults);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'should_report_missing_translation_keys' => $this->should_report_missing_translation_keys,
+ 'report_missing_translation_keys_using' => $this->report_missing_translation_keys_using,
+ 'silently_discard_exceptions' => $this->silently_discard_exceptions,
+ ];
+ }
+
+ protected function hydrateProperties(array $values, ReportingConfigurationValues $defaults): void
+ {
+ // @phpstan-ignore-next-line
+ $this->should_report_missing_translation_keys = (
+ $values['should_report_missing_translation_keys'] ?? $defaults->should_report_missing_translation_keys
+ );
+
+ // @phpstan-ignore-next-line
+ $this->report_missing_translation_keys_using = (
+ $values['report_missing_translation_keys_using'] ?? $defaults->report_missing_translation_keys_using
+ );
+
+ // @phpstan-ignore-next-line
+ $this->silently_discard_exceptions = (
+ $values['silently_discard_exceptions'] ?? $defaults->silently_discard_exceptions
+ );
+ }
+}
diff --git a/src/Config/Values/ProjectConfigurationBranching.php b/src/Config/Values/ProjectConfigurationBranching.php
new file mode 100644
index 0000000..0549e7c
--- /dev/null
+++ b/src/Config/Values/ProjectConfigurationBranching.php
@@ -0,0 +1,68 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ProjectConfigurationBranching implements Arrayable
+{
+ /**
+ * The default branch name to use in contexts where
+ * none was provided and/or none could be determined
+ * either because of limitations or configurations.
+ */
+ public readonly ?string $default_branch_name;
+
+ /**
+ * Whether local Git branches, when pushing translation
+ * lines to Transl, should be reflected on Transl.
+ */
+ public readonly bool $mirror_current_branch;
+
+ /**
+ * How detected conflicts should be handled.
+ */
+ public readonly BranchingConflictResolutionEnum $conflict_resolution;
+
+ public function __construct(
+ bool $mirror_current_branch,
+ ?string $default_branch_name,
+ string|BranchingConflictResolutionEnum $conflict_resolution,
+ ) {
+ $this->default_branch_name = $default_branch_name;
+ $this->mirror_current_branch = $mirror_current_branch;
+ $this->conflict_resolution = is_string($conflict_resolution)
+ ? BranchingConflictResolutionEnum::from($conflict_resolution)
+ : $conflict_resolution;
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(
+ ?string $default_branch_name,
+ bool $mirror_current_branch,
+ string|BranchingConflictResolutionEnum $conflict_resolution,
+ ): static {
+ return new static($mirror_current_branch, $default_branch_name, $conflict_resolution);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'default_branch_name' => $this->default_branch_name,
+ 'mirror_current_branch' => $this->mirror_current_branch,
+ 'conflict_resolution' => $this->conflict_resolution->value,
+ ];
+ }
+}
diff --git a/src/Config/Values/ProjectConfigurationLocale.php b/src/Config/Values/ProjectConfigurationLocale.php
new file mode 100644
index 0000000..a9dd408
--- /dev/null
+++ b/src/Config/Values/ProjectConfigurationLocale.php
@@ -0,0 +1,64 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ProjectConfigurationLocale implements Arrayable
+{
+ public function __construct(
+ /**
+ * The project's default locale.
+ * Usually used as a reference for other locales.
+ */
+ public readonly ?string $default,
+
+ /**
+ * The project's fallback locale.
+ */
+ public readonly ?string $fallback,
+
+ /**
+ * The locales allowed to be pushed to Transl.
+ */
+ public readonly ?array $allowed,
+
+ /**
+ * Whether to throw or silently ignore encounted
+ * locales that are not in the allowed list.
+ */
+ public readonly bool $throw_on_disallowed_locale,
+ ) {
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(
+ ?string $default,
+ ?string $fallback,
+ ?array $allowed,
+ bool $throw_on_disallowed_locale,
+ ): static {
+ return new static($default, $fallback, $allowed, $throw_on_disallowed_locale);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'default' => $this->default,
+ 'fallback' => $this->fallback,
+ 'allowed' => $this->allowed,
+ 'throw_on_disallowed_locale' => $this->throw_on_disallowed_locale,
+ ];
+ }
+}
diff --git a/src/Config/Values/ProjectConfigurationOptions.php b/src/Config/Values/ProjectConfigurationOptions.php
new file mode 100644
index 0000000..336a1d4
--- /dev/null
+++ b/src/Config/Values/ProjectConfigurationOptions.php
@@ -0,0 +1,78 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ProjectConfigurationOptions implements Arrayable
+{
+ /**
+ * A local directory used to store/cache/track
+ * necessary informations.
+ */
+ public readonly ?string $transl_directory;
+
+ /**
+ * The project's locale specific configurations.
+ */
+ public readonly ProjectConfigurationLocale $locale;
+
+ /**
+ * The project's branching specific configurations.
+ */
+ public readonly ProjectConfigurationBranching $branching;
+
+ public function __construct(
+ ?string $transl_directory,
+ array|ProjectConfigurationLocale $locale,
+ array|ProjectConfigurationBranching $branching,
+ ) {
+ $this->transl_directory = $transl_directory;
+
+ $this->hydrateLocaleProperty($locale);
+ $this->hydrateBranchingProperty($branching);
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(
+ ?string $transl_directory,
+ array|ProjectConfigurationLocale $locale,
+ array|ProjectConfigurationBranching $branching,
+ ): static {
+ return new static($transl_directory, $locale, $branching);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'transl_directory' => $this->transl_directory,
+ 'locale' => $this->locale->toArray(),
+ 'branching' => $this->branching->toArray(),
+ ];
+ }
+
+ protected function hydrateLocaleProperty(array|ProjectConfigurationLocale $locale): void
+ {
+ // @phpstan-ignore-next-line
+ $this->locale = is_array($locale) ? ProjectConfigurationLocale::new(...$locale) : $locale;
+ }
+
+ protected function hydrateBranchingProperty(array|ProjectConfigurationBranching $branching): void
+ {
+ // @phpstan-ignore-next-line
+ $this->branching = is_array($branching) ? ProjectConfigurationBranching::new(...$branching) : $branching;
+ }
+}
diff --git a/src/Drivers/LocalFilesDriver.php b/src/Drivers/LocalFilesDriver.php
new file mode 100644
index 0000000..ce5499f
--- /dev/null
+++ b/src/Drivers/LocalFilesDriver.php
@@ -0,0 +1,349 @@
+get_translation_contents_action)
+ ->usingDriver($this)
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingLanguageDirectories($this->languageDirectories($project, $branch))
+ ->shouldIgnorePackageTranslations($this->ignore_package_translations)
+ ->shouldIgnoreVendorTranslations($this->ignore_vendor_translations)
+ ->execute($locale, $group, $namespace);
+ }
+
+ /**
+ * Converts "raw" translation messages/contents to
+ * an instance of `\Transl\Support\TranslationSet`.
+ */
+ public function translationContentsToTranslationSet(
+ ProjectConfiguration $project,
+ Branch $branch,
+ array $contents,
+ string $locale,
+ ?string $group,
+ ?string $namespace,
+ ?array $meta,
+ ): TranslationSet {
+ return app($this->translation_contents_to_translation_set_action)
+ ->usingDriver($this)
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingLanguageDirectories($this->languageDirectories($project, $branch))
+ ->shouldIgnorePackageTranslations($this->ignore_package_translations)
+ ->shouldIgnoreVendorTranslations($this->ignore_vendor_translations)
+ ->execute($contents, $locale, $group, $namespace, $meta);
+ }
+
+ /* Translation set
+ ------------------------------------------------*/
+
+ /**
+ * Count the amount of translation sets to be retrieved.
+ *
+ * @param (callable(string $locale, ?string $group, ?string $namespace): bool)|null $filter
+ * @param (callable(TranslationSet $translationSet): void)|null $onSkipped
+ */
+ public function countTranslationSets(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?callable $filter = null,
+ ?callable $onSkipped = null,
+ ): int {
+ return app($this->count_translation_sets_action)
+ ->usingDriver($this)
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingFilter($filter)
+ ->onSkipped($onSkipped)
+ ->usingLanguageDirectories($this->languageDirectories($project, $branch))
+ ->shouldIgnorePackageTranslations($this->ignore_package_translations)
+ ->shouldIgnoreVendorTranslations($this->ignore_vendor_translations)
+ ->execute();
+ }
+
+ /**
+ * Get a collection of `\Transl\Support\TranslationSet`s.
+ *
+ * @param (callable(string $locale, ?string $group, ?string $namespace): bool)|null $filter
+ * @param (callable(TranslationSet $translationSet): void)|null $onSkipped
+ * @return iterable
+ */
+ public function getTranslationSets(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?callable $filter = null,
+ ?callable $onSkipped = null,
+ ): iterable {
+ return app($this->get_translation_sets_action)
+ ->usingDriver($this)
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingFilter($filter)
+ ->onSkipped($onSkipped)
+ ->usingLanguageDirectories($this->languageDirectories($project, $branch))
+ ->shouldIgnorePackageTranslations($this->ignore_package_translations)
+ ->shouldIgnoreVendorTranslations($this->ignore_vendor_translations)
+ ->execute();
+ }
+
+ /**
+ * Retrieves a `\Transl\Support\TranslationSet`.
+ */
+ public function getTranslationSet(
+ ProjectConfiguration $project,
+ Branch $branch,
+ string $locale,
+ ?string $group,
+ ?string $namespace,
+ ?array $meta,
+ ): TranslationSet {
+ $contents = $this->getTranslationContents(
+ $project,
+ $branch,
+ $locale,
+ $group,
+ $namespace,
+ );
+
+ return $this->translationContentsToTranslationSet(
+ $project,
+ $branch,
+ $contents,
+ $locale,
+ $group,
+ $namespace,
+ $meta,
+ );
+ }
+
+ /**
+ * Stores a `\Transl\Support\TranslationSet` in a way
+ * that should be readable to a Laravel translation loader.
+ */
+ public function saveTranslationSet(ProjectConfiguration $project, Branch $branch, TranslationSet $set): void
+ {
+ app($this->save_translation_set_action)
+ ->usingDriver($this)
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingTranslationSet($set)
+ ->usingLanguageDirectories($this->languageDirectories($project, $branch))
+ ->shouldIgnorePackageTranslations($this->ignore_package_translations)
+ ->shouldIgnoreVendorTranslations($this->ignore_vendor_translations)
+ ->execute();
+ }
+
+ /* Tracked translation set
+ ------------------------------------------------*/
+
+ /**
+ * Get a `\Transl\Support\TranslationSet` that has
+ * previously been pushed to Transl, thus, "tracked" by Transl.
+ * This translation set will be used in determining conflicts.
+ */
+ public function getTrackedTranslationSet(ProjectConfiguration $project, Branch $branch, TranslationSet $set): ?TranslationSet
+ {
+ return app($this->get_tracked_translation_set_action)
+ ->usingDriver($this)
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingLanguageDirectories($this->languageDirectories($project, $branch))
+ ->shouldIgnorePackageTranslations($this->ignore_package_translations)
+ ->shouldIgnoreVendorTranslations($this->ignore_vendor_translations)
+ ->execute($set);
+ }
+
+ /**
+ * Stores a `\Transl\Support\TranslationSet` in a way
+ * that would allow it to be reconstructed back (using `TranslationSet::from` for example).
+ */
+ public function saveTrackedTranslationSet(ProjectConfiguration $project, Branch $branch, TranslationSet $set): void
+ {
+ app($this->save_tracked_translation_set_action)
+ ->usingDriver($this)
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingLanguageDirectories($this->languageDirectories($project, $branch))
+ ->shouldIgnorePackageTranslations($this->ignore_package_translations)
+ ->shouldIgnoreVendorTranslations($this->ignore_vendor_translations)
+ ->execute($set);
+ }
+
+ /* Helpers
+ ------------------------------------------------*/
+
+ public function defaultLanguageDirectories(ProjectConfiguration $project, Branch $branch): array
+ {
+ return [lang_path()];
+ }
+
+ public function filesystem(): Filesystem
+ {
+ return app(Filesystem::class);
+ }
+
+ public function translationLoader(): Loader
+ {
+ $loader = $this->translator()->getLoader();
+
+ $this->ensureTranslationLoaderIsSupported($loader);
+
+ return $loader;
+ }
+
+ public function getTrackedTranslationSetPath(
+ ProjectConfiguration $project,
+ Branch $branch,
+ TranslationSet $set,
+ ): ?string {
+ $base = $project->options->transl_directory;
+
+ if (!$base) {
+ return null;
+ }
+
+ return "{$base}/{$project->auth_key}/{$branch->name}/tracked/{$set->trackingKey()}.json";
+ }
+
+ protected function translator(): Translator
+ {
+ return app('translator');
+ }
+
+ protected function ensureTranslationLoaderIsSupported(Loader $loader): void
+ {
+ if ($loader instanceof FileLoader) {
+ return;
+ }
+
+ throw UnsupportedTranslationLoader::make($loader::class, static::class, FileLoader::class);
+ }
+
+ protected function languageDirectories(ProjectConfiguration $project, Branch $branch): array
+ {
+ return is_null($this->language_directories)
+ ? $this->defaultLanguageDirectories($project, $branch)
+ : $this->language_directories;
+ }
+}
diff --git a/src/Exceptions/Branching/ConflictException.php b/src/Exceptions/Branching/ConflictException.php
new file mode 100644
index 0000000..2f6cb56
--- /dev/null
+++ b/src/Exceptions/Branching/ConflictException.php
@@ -0,0 +1,12 @@
+translationKey()}` while pulling the branch `{$branch->name}` of the project `{$project->label()}`.",
+ );
+ }
+}
diff --git a/src/Exceptions/Console/TranslConsoleException.php b/src/Exceptions/Console/TranslConsoleException.php
new file mode 100644
index 0000000..f981e05
--- /dev/null
+++ b/src/Exceptions/Console/TranslConsoleException.php
@@ -0,0 +1,18 @@
+locale}`";
+
+ if ($set->group) {
+ $setAsString .= ", group:`{$set->group}`";
+ }
+
+ if ($set->namespace) {
+ $setAsString .= ", namespace:`{$set->namespace}`";
+ }
+
+ return static::message(
+ "Could not determine a translation file's relative path for the translation set [{$setAsString}] provided to the driver `{$driverClass}`.",
+ );
+ }
+}
diff --git a/src/Exceptions/LocalFilesDriver/CouldNotOpenLanguageDirectory.php b/src/Exceptions/LocalFilesDriver/CouldNotOpenLanguageDirectory.php
new file mode 100644
index 0000000..361f555
--- /dev/null
+++ b/src/Exceptions/LocalFilesDriver/CouldNotOpenLanguageDirectory.php
@@ -0,0 +1,18 @@
+fullPath()}` provided to the driver `{$driverClass}` could not be opened.",
+ );
+ }
+}
diff --git a/src/Exceptions/LocalFilesDriver/FoundDisallowedProjectLocale.php b/src/Exceptions/LocalFilesDriver/FoundDisallowedProjectLocale.php
new file mode 100644
index 0000000..c7e65d1
--- /dev/null
+++ b/src/Exceptions/LocalFilesDriver/FoundDisallowedProjectLocale.php
@@ -0,0 +1,25 @@
+options->locale->allowed;
+ $message = "Encountered a locale `{$found}` that is not in the `options.locale.allowed` array of the project `{$project->name}`.";
+
+ if (!empty($allowed)) {
+ $allowed = implode(', ', $allowed);
+
+ $message .= " Allowed locales are: `{$allowed}`.";
+ }
+
+ return static::message($message);
+ }
+}
diff --git a/src/Exceptions/LocalFilesDriver/LocalFilesDriverException.php b/src/Exceptions/LocalFilesDriver/LocalFilesDriverException.php
new file mode 100644
index 0000000..63d4ef2
--- /dev/null
+++ b/src/Exceptions/LocalFilesDriver/LocalFilesDriverException.php
@@ -0,0 +1,12 @@
+locale;
+
+ // For JSON translations, there is only one file per locale, so we will simply load
+ // that file and then we will be ready to check the array for the key. These are
+ // only one level deep so we do not need to do any fancy searching through it.
+ $this->load('*', '*', $locale);
+
+ $line = $this->loaded['*']['*'][$locale][$key] ?? null;
+
+ // If we can't find a translation for the JSON key, we will attempt to translate it
+ // using the typical translation file. This way developers can always just use a
+ // helper such as __ instead of having to pick between trans or __ with views.
+ if (! isset($line)) {
+ [$namespace, $group, $item] = $this->parseKey($key);
+
+ // Here we will get the locale that should be used for the language line. If one
+ // was not passed, we will use the default locales which was given to us when
+ // the translator was instantiated. Then, we can load the lines and return.
+ $locales = $fallback ? $this->localeArray($locale) : [$locale];
+
+ foreach ($locales as $languageLineLocale) {
+ if (! is_null($line = $this->getLine(
+ $namespace, $group, $languageLineLocale, $item, $replace
+ ))) {
+ return $line;
+ }
+ }
+
+ $key = $this->handleMissingTranslationKey(
+ $key, $replace, $locale, $fallback
+ );
+ }
+
+ // If the line doesn't exist, we will return back the key which was requested as
+ // that will be quick to spot in the UI if language keys are wrong or missing
+ // from the application's language files. Otherwise we can return the line.
+ return $this->makeReplacements($line ?: $key, $replace);
+ }
+
+ /**
+ * Handle a missing translation key.
+ *
+ * @param string $key
+ * @param array $replace
+ * @param string|null $locale
+ * @param bool $fallback
+ * @return string
+ */
+ protected function handleMissingTranslationKey($key, $replace, $locale, $fallback)
+ {
+ if (! $this->handleMissingTranslationKeys ||
+ ! isset($this->missingTranslationKeyCallback)) {
+ return $key;
+ }
+
+ // Prevent infinite loops...
+ $this->handleMissingTranslationKeys = false;
+
+ $key = call_user_func(
+ $this->missingTranslationKeyCallback,
+ $key, $replace, $locale, $fallback
+ ) ?? $key;
+
+ $this->handleMissingTranslationKeys = true;
+
+ return $key;
+ }
+
+ /**
+ * Register a callback that is responsible for handling missing translation keys.
+ *
+ * @param callable|null $callback
+ * @return static
+ */
+ public function handleMissingKeysUsing(?callable $callback)
+ {
+ $this->missingTranslationKeyCallback = $callback;
+
+ return $this;
+ }
+}
diff --git a/src/Support/Analysis/ProjectAnalysis.php b/src/Support/Analysis/ProjectAnalysis.php
new file mode 100644
index 0000000..8880d77
--- /dev/null
+++ b/src/Support/Analysis/ProjectAnalysis.php
@@ -0,0 +1,101 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ProjectAnalysis implements Arrayable
+{
+ public function __construct(
+ public readonly ProjectAnalysisSummary $summary,
+ /**
+ * @var array
+ */
+ public readonly array $locales,
+ ) {
+ }
+
+ /**
+ * @param array $locales
+ */
+ public static function new(ProjectAnalysisSummary $summary, array $locales): static
+ {
+ return new static($summary, $locales);
+ }
+
+ /**
+ * @param iterable $sets
+ */
+ public static function fromTranslationSets(iterable $sets): static
+ {
+ $uniqueTranslationKeys = [];
+ $uniqueTranslationSets = [];
+ $locales = [];
+ $analysedLocales = [];
+
+ foreach ($sets as $set) {
+ $translationSetKey = $set->translationKey();
+ $translationSetLabel = $translationSetKey === '' ? 'UNGROUPED' : $translationSetKey;
+
+ $uniqueTranslationSets[$translationSetKey] = 1;
+
+ $lines = $set
+ ->lines
+ ->toBase()
+ ->reduce(static function (array $acc, TranslationLine $line) use ($translationSetKey, &$uniqueTranslationKeys): array {
+ $translationKey = $translationSetKey === '' ? $line->key : "{$translationSetKey}.{$line->key}";
+
+ $uniqueTranslationKeys[$translationKey] = ($uniqueTranslationKeys[$translationKey] ?? 0) + 1;
+
+ $acc[$line->key] = TranslationLineAnalysis::fromTranslationLine($line);
+
+ return $acc;
+ }, []);
+
+ ksort($lines);
+
+ $locales[$set->locale][$translationSetLabel] = TranslationSetAnalysis::fromAnalysedLines($lines);
+ }
+
+ foreach ($locales as $locale => $analysedSets) {
+ ksort($analysedSets);
+
+ $analysedLocales[$locale] = TranslationLocaleAnalysis::fromAnalysedSets($analysedSets);
+ }
+
+ ksort($analysedLocales);
+
+ return static::new(
+ summary: ProjectAnalysisSummary::partiallyFromAnalysedLocales(
+ unique_translation_key_count: count($uniqueTranslationKeys),
+ unique_translation_set_count: count($uniqueTranslationSets),
+ translation_key_count: array_sum($uniqueTranslationKeys),
+ locales: $analysedLocales,
+ ),
+ locales: $analysedLocales,
+ );
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'summary' => $this->summary->toArray(),
+ 'locales' => collect($this->locales)->toArray(),
+ ];
+ }
+}
diff --git a/src/Support/Analysis/ProjectAnalysisSummary.php b/src/Support/Analysis/ProjectAnalysisSummary.php
new file mode 100644
index 0000000..f0b3002
--- /dev/null
+++ b/src/Support/Analysis/ProjectAnalysisSummary.php
@@ -0,0 +1,84 @@
+
+ * @phpstan-consistent-constructor
+ */
+class ProjectAnalysisSummary implements Arrayable
+{
+ public function __construct(
+ public readonly int $unique_translation_key_count,
+ public readonly int $unique_translation_set_count,
+ public readonly int $translation_key_count,
+ public readonly int $translation_set_count,
+ public readonly int $translation_line_count,
+ public readonly int $translation_line_word_count,
+ public readonly int $translation_line_character_count,
+ ) {
+ }
+
+ public static function new(
+ int $unique_translation_key_count,
+ int $unique_translation_set_count,
+ int $translation_key_count,
+ int $translation_set_count,
+ int $translation_line_count,
+ int $translation_line_word_count,
+ int $translation_line_character_count,
+ ): static {
+ return new static(
+ unique_translation_key_count: $unique_translation_key_count,
+ unique_translation_set_count: $unique_translation_set_count,
+ translation_key_count: $translation_key_count,
+ translation_set_count: $translation_set_count,
+ translation_line_count: $translation_line_count,
+ translation_line_word_count: $translation_line_word_count,
+ translation_line_character_count: $translation_line_character_count,
+ );
+ }
+
+ /**
+ * @param array $locales
+ */
+ public static function partiallyFromAnalysedLocales(
+ int $unique_translation_key_count,
+ int $unique_translation_set_count,
+ int $translation_key_count,
+ array $locales,
+ ): static {
+ $summaries = array_column($locales, 'summary');
+
+ return static::new(
+ unique_translation_key_count: $unique_translation_key_count,
+ unique_translation_set_count: $unique_translation_set_count,
+ translation_key_count: $translation_key_count,
+ translation_set_count: array_sum(array_column($summaries, 'translation_set_count')),
+ translation_line_count: array_sum(array_column($summaries, 'translation_line_count')),
+ translation_line_word_count: array_sum(array_column($summaries, 'translation_line_word_count')),
+ translation_line_character_count: array_sum(array_column($summaries, 'translation_line_character_count')),
+ );
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'unique_translation_key_count' => $this->unique_translation_key_count,
+ 'unique_translation_set_count' => $this->unique_translation_set_count,
+ 'translation_key_count' => $this->translation_key_count,
+ 'translation_set_count' => $this->translation_set_count,
+ 'translation_line_count' => $this->translation_line_count,
+ 'translation_line_word_count' => $this->translation_line_word_count,
+ 'translation_line_character_count' => $this->translation_line_character_count,
+ ];
+ }
+}
diff --git a/src/Support/Analysis/TranslationLineAnalysis.php b/src/Support/Analysis/TranslationLineAnalysis.php
new file mode 100644
index 0000000..286d876
--- /dev/null
+++ b/src/Support/Analysis/TranslationLineAnalysis.php
@@ -0,0 +1,66 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationLineAnalysis implements Arrayable
+{
+ public function __construct(
+ public readonly int $word_count,
+ public readonly int $character_count,
+ ) {
+ }
+
+ public static function new(int $word_count, int $character_count): static
+ {
+ return new static($word_count, $character_count);
+ }
+
+ public static function fromString(string $value): static
+ {
+ return static::new(
+ word_count: static::valueWordCount($value),
+ character_count: static::valueCharacterCount($value),
+ );
+ }
+
+ public static function fromTranslationLine(TranslationLine $line): static
+ {
+ return static::fromString($line->valueAsString());
+ }
+
+ protected static function valueWordCount(string $value): int
+ {
+ // return Str::wordCount($value);
+ return collect(explode(' ', $value))
+ ->map(static fn (string $value): string => trim($value))
+ ->filter()
+ ->values()
+ ->count();
+ }
+
+ protected static function valueCharacterCount(string $value): int
+ {
+ return Str::length($value);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'word_count' => $this->word_count,
+ 'character_count' => $this->character_count,
+ ];
+ }
+}
diff --git a/src/Support/Analysis/TranslationLocaleAnalysis.php b/src/Support/Analysis/TranslationLocaleAnalysis.php
new file mode 100644
index 0000000..4408cbd
--- /dev/null
+++ b/src/Support/Analysis/TranslationLocaleAnalysis.php
@@ -0,0 +1,60 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationLocaleAnalysis implements Arrayable
+{
+ public function __construct(
+ public readonly TranslationLocaleSetsAnalysisSummary $summary,
+ /**
+ * @var array
+ */
+ public readonly array $translation_sets,
+ ) {
+ }
+
+ /**
+ * @param array $translation_sets
+ */
+ public static function new(
+ TranslationLocaleSetsAnalysisSummary $summary,
+ array $translation_sets,
+ ): static {
+ return new static (
+ summary: $summary,
+ translation_sets: $translation_sets,
+ );
+ }
+
+ /**
+ * @param array $sets
+ */
+ public static function fromAnalysedSets(array $sets): static
+ {
+ return static::new(
+ summary: TranslationLocaleSetsAnalysisSummary::fromAnalysedSets($sets),
+ translation_sets: $sets,
+ );
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'summary' => $this->summary->toArray(),
+ 'translation_sets' => collect($this->translation_sets)->toArray(),
+ ];
+ }
+}
diff --git a/src/Support/Analysis/TranslationLocaleSetsAnalysisSummary.php b/src/Support/Analysis/TranslationLocaleSetsAnalysisSummary.php
new file mode 100644
index 0000000..94031e9
--- /dev/null
+++ b/src/Support/Analysis/TranslationLocaleSetsAnalysisSummary.php
@@ -0,0 +1,65 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationLocaleSetsAnalysisSummary implements Arrayable
+{
+ public function __construct(
+ public readonly int $translation_set_count,
+ public readonly int $translation_line_count,
+ public readonly int $translation_line_word_count,
+ public readonly int $translation_line_character_count,
+ ) {
+ }
+
+ public static function new(
+ int $translation_set_count,
+ int $translation_line_count,
+ int $translation_line_word_count,
+ int $translation_line_character_count,
+ ): static {
+ return new static(
+ translation_set_count: $translation_set_count,
+ translation_line_count: $translation_line_count,
+ translation_line_word_count: $translation_line_word_count,
+ translation_line_character_count: $translation_line_character_count,
+ );
+ }
+
+ /**
+ * @param array $sets
+ */
+ public static function fromAnalysedSets(array $sets): static
+ {
+ $summaries = array_column($sets, 'summary');
+
+ return static::new(
+ translation_set_count: count($sets),
+ translation_line_count: array_sum(array_column($summaries, 'translation_line_count')),
+ translation_line_word_count: array_sum(array_column($summaries, 'translation_line_word_count')),
+ translation_line_character_count: array_sum(array_column($summaries, 'translation_line_character_count')),
+ );
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'translation_set_count' => $this->translation_set_count,
+ 'translation_line_count' => $this->translation_line_count,
+ 'translation_line_word_count' => $this->translation_line_word_count,
+ 'translation_line_character_count' => $this->translation_line_character_count,
+ ];
+ }
+}
diff --git a/src/Support/Analysis/TranslationSetAnalysis.php b/src/Support/Analysis/TranslationSetAnalysis.php
new file mode 100644
index 0000000..7c09017
--- /dev/null
+++ b/src/Support/Analysis/TranslationSetAnalysis.php
@@ -0,0 +1,68 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationSetAnalysis implements Arrayable
+{
+ public function __construct(
+ public readonly TranslationSetLinesAnalysisSummary $summary,
+ /**
+ * @var array
+ */
+ public readonly array $lines,
+ ) {
+ }
+
+ public static function new(TranslationSetLinesAnalysisSummary $summary, array $lines): static
+ {
+ return new static($summary, $lines);
+ }
+
+ /**
+ * @param array $lines
+ */
+ public static function fromAnalysedLines(array $lines): static
+ {
+ return static::new(
+ summary: TranslationSetLinesAnalysisSummary::fromAnalysedLines($lines),
+ lines: $lines,
+ );
+ }
+
+ // public static function fromTranslationSet(TranslationSet $set): static
+ // {
+ // $lines = $set
+ // ->lines
+ // ->toBase()
+ // ->reduce(function (array $acc, TranslationLine $line): array {
+ // $acc[$line->key] = TranslationLineAnalysis::fromTranslationLine($line);
+
+ // return $acc;
+ // }, []);
+
+ // return static::fromAnalysedLines($lines);
+ // }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'summary' => $this->summary->toArray(),
+ 'lines' => collect($this->lines)->toArray(),
+ ];
+ }
+}
diff --git a/src/Support/Analysis/TranslationSetLinesAnalysisSummary.php b/src/Support/Analysis/TranslationSetLinesAnalysisSummary.php
new file mode 100644
index 0000000..e92d1cb
--- /dev/null
+++ b/src/Support/Analysis/TranslationSetLinesAnalysisSummary.php
@@ -0,0 +1,53 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationSetLinesAnalysisSummary implements Arrayable
+{
+ public function __construct(
+ public readonly int $translation_line_count,
+ public readonly int $translation_line_word_count,
+ public readonly int $translation_line_character_count,
+ ) {
+ }
+
+ public static function new(
+ int $translation_line_count,
+ int $translation_line_word_count,
+ int $translation_line_character_count,
+ ): static {
+ return new static($translation_line_count, $translation_line_word_count, $translation_line_character_count);
+ }
+
+ /**
+ * @param array $lines
+ */
+ public static function fromAnalysedLines(array $lines): static
+ {
+ return static::new(
+ translation_line_count: count($lines),
+ translation_line_word_count: array_sum(array_column($lines, 'word_count')),
+ translation_line_character_count: array_sum(array_column($lines, 'character_count')),
+ );
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'translation_line_count' => $this->translation_line_count,
+ 'translation_line_word_count' => $this->translation_line_word_count,
+ 'translation_line_character_count' => $this->translation_line_character_count,
+ ];
+ }
+}
diff --git a/src/Support/Api.php b/src/Support/Api.php
new file mode 100644
index 0000000..436db33
--- /dev/null
+++ b/src/Support/Api.php
@@ -0,0 +1,24 @@
+
+ * @phpstan-consistent-constructor
+ */
+class Branch implements Arrayable
+{
+ public function __construct(
+ /**
+ * The branch name.
+ */
+ public readonly string $name,
+ /**
+ * If the branch has been provided by the user.
+ */
+ public readonly bool $is_provided,
+ /**
+ * If the branch is the determined current working branch.
+ */
+ public readonly bool $is_current,
+ /**
+ * If the branch is the determined default.
+ */
+ public readonly bool $is_default,
+ /**
+ * If the branch is a fallback in lieu of the determined default.
+ */
+ public readonly bool $is_fallback,
+ ) {
+ }
+
+ public static function new(string $name, bool $is_provided, bool $is_current, bool $is_default, bool $is_fallback): static
+ {
+ return new static($name, $is_provided, $is_current, $is_default, $is_fallback);
+ }
+
+ /**
+ * Set the branch as being provided by the user.
+ */
+ public static function asProvided(string $name): static
+ {
+ return static::new($name, true, false, false, false);
+ }
+
+ /**
+ * Set the branch as being the determined current working branch.
+ */
+ public static function asCurrent(string $name): static
+ {
+ return static::new($name, false, true, false, false);
+ }
+
+ /**
+ * Set the branch as being the determined default.
+ */
+ public static function asDefault(string $name): static
+ {
+ return static::new($name, false, false, true, false);
+ }
+
+ /**
+ * Set the branch as being a fallback in lieu of the determined default.
+ */
+ public static function asFallback(string $name): static
+ {
+ return static::new($name, false, false, false, true);
+ }
+
+ /**
+ * Get the branch's provenance as a string
+ * if the information is available.
+ */
+ public function provenance(): ?string
+ {
+ $value = null;
+
+ if ($this->is_provided) {
+ $value = 'provided';
+ }
+
+ if ($this->is_current) {
+ $value = 'current';
+ }
+
+ if ($this->is_default) {
+ $value = 'default';
+ }
+
+ if ($this->is_fallback) {
+ $value = 'fallback';
+ }
+
+ return $value;
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'is_provided' => $this->is_provided,
+ 'is_current' => $this->is_current,
+ 'is_default' => $this->is_default,
+ 'is_fallback' => $this->is_fallback,
+ ];
+ }
+}
diff --git a/src/Support/Commands.php b/src/Support/Commands.php
new file mode 100644
index 0000000..f3d9b38
--- /dev/null
+++ b/src/Support/Commands.php
@@ -0,0 +1,36 @@
+
+ */
+ public function getTranslationSets(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?callable $filter = null,
+ ?callable $onSkipped = null,
+ ): iterable;
+
+ /**
+ * Retrieves a `\Transl\Support\TranslationSet`.
+ */
+ public function getTranslationSet(
+ ProjectConfiguration $project,
+ Branch $branch,
+ string $locale,
+ ?string $group,
+ ?string $namespace,
+ ?array $meta,
+ ): TranslationSet;
+
+ /**
+ * Stores a `\Transl\Support\TranslationSet` in a way
+ * that should be readable to a Laravel translation loader.
+ */
+ public function saveTranslationSet(
+ ProjectConfiguration $project,
+ Branch $branch,
+ TranslationSet $set,
+ ): void;
+
+ /* Tracked translation set
+ ------------------------------------------------*/
+
+ /**
+ * Get a `\Transl\Support\TranslationSet` that has
+ * previously been pushed to Transl, thus, "tracked" by Transl.
+ * This translation set will be used in determining conflicts.
+ */
+ public function getTrackedTranslationSet(
+ ProjectConfiguration $project,
+ Branch $branch,
+ TranslationSet $set,
+ ): ?TranslationSet;
+
+ /**
+ * Stores a `\Transl\Support\TranslationSet` in a way
+ * that would allow it to be reconstructed back (using `TranslationSet::from` for example).
+ */
+ public function saveTrackedTranslationSet(
+ ProjectConfiguration $project,
+ Branch $branch,
+ TranslationSet $set,
+ ): void;
+}
diff --git a/src/Support/Git.php b/src/Support/Git.php
new file mode 100644
index 0000000..7ddb5d8
--- /dev/null
+++ b/src/Support/Git.php
@@ -0,0 +1,56 @@
+ https://github.com/git/git/releases/tag/v2.28.0).
+ * @see https://git-scm.com/docs/git-config#Documentation/git-config.txt-initdefaultBranch
+ * @see https://github.com/git/git/commit/8747ebb7cde9e90d20794c06e6806f75cd540142
+ */
+ return 'git config --get init.defaultBranch';
+ }
+
+ public static function getCurrentBranchNameCommand(): string
+ {
+ /**
+ * Since v2.22 (around Jun 7, 2019 -> https://github.com/git/git/releases/tag/v2.22.0).
+ * @see https://git-scm.com/docs/git-branch#Documentation/git-branch.txt---show-current
+ */
+ return 'git branch --show-current';
+ }
+
+ protected static function run(string $command): ?string
+ {
+ $result = Process::run($command);
+
+ if ($result->failed()) {
+ return null;
+ }
+
+ $output = trim($result->output());
+
+ if (blank($output)) {
+ return null;
+ }
+
+ return $output;
+ }
+}
diff --git a/src/Support/Helper.php b/src/Support/Helper.php
new file mode 100644
index 0000000..5ecb003
--- /dev/null
+++ b/src/Support/Helper.php
@@ -0,0 +1,60 @@
+
+ // */
+ // use EnumeratesValues;
+
+ // public function items(mixed $items): array
+ // {
+ // return $this->getArrayableItems($items);
+ // }
+ // }
+ // )->items($items);
+ // }
+}
diff --git a/src/Support/LocaleFilesystem/FilePath.php b/src/Support/LocaleFilesystem/FilePath.php
new file mode 100644
index 0000000..30cd2da
--- /dev/null
+++ b/src/Support/LocaleFilesystem/FilePath.php
@@ -0,0 +1,183 @@
+
+ * @phpstan-consistent-constructor
+ */
+class FilePath implements Arrayable
+{
+ protected string $root;
+ protected string $relativePath;
+ protected string $directorySeparator;
+
+ public function __construct(string $root, string $relativePath = '', string $directorySeparator = DIRECTORY_SEPARATOR)
+ {
+ $this->root = $root;
+ $this->relativePath = $relativePath;
+ $this->directorySeparator = $directorySeparator;
+
+ $this->standardizePaths();
+ }
+
+ public static function new(string $root, string $relativePath = '', string $directorySeparator = DIRECTORY_SEPARATOR): static
+ {
+ return new static($root, $relativePath, $directorySeparator);
+ }
+
+ public static function wrap(string|FilePath $path): static
+ {
+ if (is_string($path)) {
+ $path = static::new($path);
+ }
+
+ /** @var static $path */
+ return $path;
+ }
+
+ public function withDirectorySeparator(string $directorySeparator): static
+ {
+ $this->directorySeparator = $directorySeparator;
+
+ $this->standardizePaths();
+
+ return $this;
+ }
+
+ public function root(): string
+ {
+ return $this->root;
+ }
+
+ public function relativePath(): string
+ {
+ return $this->relativePath;
+ }
+
+ public function directorySeparator(): string
+ {
+ return $this->directorySeparator;
+ }
+
+ public function fullPath(): string
+ {
+ return $this->standardizePathTrailingDirectorySeparator(
+ "{$this->root()}{$this->directorySeparator()}{$this->relativePath()}",
+ $this->directorySeparator(),
+ );
+ }
+
+ public function directoryName(): string
+ {
+ return pathinfo(pathinfo($this->fullPath(), PATHINFO_DIRNAME), PATHINFO_FILENAME);
+ }
+
+ public function fileName(): string
+ {
+ return pathinfo($this->fullPath(), PATHINFO_BASENAME);
+ }
+
+ public function fileNameWithoutExtension(): string
+ {
+ return pathinfo($this->fullPath(), PATHINFO_FILENAME);
+ }
+
+ public function extension(): string
+ {
+ return pathinfo($this->fullPath(), PATHINFO_EXTENSION);
+ }
+
+ public function append(string $path): static
+ {
+ return static::new(
+ $this->fullPath(),
+ $path,
+ $this->directorySeparator(),
+ );
+ }
+
+ public function relativeFrom(string|FilePath $root): string
+ {
+ if ($root instanceof FilePath) {
+ $root = $root->fullPath();
+ }
+
+ $root = $this->standardizePathDirectorySeparator($root, $this->directorySeparator());
+ $root = $this->standardizePathTrailingDirectorySeparator($root, $this->directorySeparator());
+
+ $fullPath = $this->fullPath();
+
+ $value = str_replace($root, '', $fullPath);
+
+ if ($value === $fullPath) {
+ return $value;
+ }
+
+ return $this->standardizePathLeadingAndTrailingDirectorySeparators($value, $this->directorySeparator());
+ }
+
+ public function exists(): bool
+ {
+ return file_exists($this->fullPath());
+ }
+
+ public function isDirectory(): bool
+ {
+ return is_dir($this->fullPath());
+ }
+
+ public function isFile(): bool
+ {
+ return is_file($this->fullPath());
+ }
+
+ public function isNestedWithin(string $root): bool
+ {
+ $root = $this->standardizePathDirectorySeparator($root, $this->directorySeparator());
+ $fullPath = $this->fullPath();
+
+ return ($fullPath !== $root) && str_starts_with($fullPath, $root);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'root' => $this->root(),
+ 'relative_path' => $this->relativePath(),
+ 'directory_separator' => $this->directorySeparator(),
+ 'full_path' => $this->fullPath(),
+ ];
+ }
+
+ protected function standardizePaths(): void
+ {
+ $this->root = $this->standardizePathDirectorySeparator($this->root, $this->directorySeparator());
+ $this->relativePath = $this->standardizePathDirectorySeparator($this->relativePath, $this->directorySeparator());
+
+ $this->root = $this->standardizePathTrailingDirectorySeparator($this->root, $this->directorySeparator());
+ $this->relativePath = $this->standardizePathLeadingAndTrailingDirectorySeparators($this->relativePath, $this->directorySeparator());
+ }
+
+ protected function standardizePathDirectorySeparator(string $path, string $directorySeparator): string
+ {
+ return str_replace(['\\', '\\\\', '/', '//'], $directorySeparator, $path);
+ }
+
+ protected function standardizePathTrailingDirectorySeparator(string $path, string $directorySeparator): string
+ {
+ return rtrim($path, $directorySeparator);
+ }
+
+ protected function standardizePathLeadingAndTrailingDirectorySeparators(string $path, string $directorySeparator): string
+ {
+ return trim($path, $directorySeparator);
+ }
+}
diff --git a/src/Support/LocaleFilesystem/LangFilePath.php b/src/Support/LocaleFilesystem/LangFilePath.php
new file mode 100644
index 0000000..fc39951
--- /dev/null
+++ b/src/Support/LocaleFilesystem/LangFilePath.php
@@ -0,0 +1,112 @@
+relativeFrom($languageDirectory->append("vendor/{$packageName}"));
+ }
+
+ $path = $this->relativeFrom($languageDirectory->append('vendor'));
+
+ return $this->afterFirstSegmentInPath($path, $this->directorySeparator());
+ }
+
+ public function guessLocale(string|FilePath $languageDirectory): string
+ {
+ if ($this->isJson()) {
+ return $this->fileNameWithoutExtension();
+ }
+
+ return $this->getFirstSegmentInPath(
+ $this->relativeFromBaseOrPackage($languageDirectory),
+ $this->directorySeparator(),
+ );
+ }
+
+ public function guessGroup(string|FilePath $languageDirectory): ?string
+ {
+ if ($this->isJson()) {
+ return null;
+ }
+
+ $path = $this->afterFirstSegmentInPath(
+ $this->relativeFromBaseOrPackage($languageDirectory),
+ $this->directorySeparator(),
+ );
+
+ /**
+ * Example:
+ * - /en/auth.php -> auth
+ * - /en/pages/dashboard/nav.php -> pages/dashboard/nav
+ *
+ * Note: https://github.com/laravel/docs/pull/3957
+ * > Laravel localization doesn't follow the usual "dot notation" for files in nested directories.
+ * > This behavior is not intentional and thus may not be supported in the future so would rather not document it.
+ */
+ return str_replace([".{$this->extension()}", $this->directorySeparator()], ['', '/'], $path);
+ }
+
+ public function guessNamespace(string|FilePath $languageDirectory): ?string
+ {
+ if ($this->isJson()) {
+ return null;
+ }
+
+ if (!$this->isPackage()) {
+ return null;
+ }
+
+ $languageDirectory = FilePath::wrap($languageDirectory);
+
+ return $this->getFirstSegmentInPath(
+ $this->relativeFrom($languageDirectory->append('vendor')),
+ $this->directorySeparator(),
+ );
+ }
+
+ public function isJson(): bool
+ {
+ return $this->extension() === 'json';
+ }
+
+ public function isPhp(): bool
+ {
+ return $this->extension() === 'php';
+ }
+
+ public function isPackage(): bool
+ {
+ return str_starts_with($this->relativePath(), 'vendor' . $this->directorySeparator());
+ }
+
+ public function inVendor(): bool
+ {
+ return str_contains($this->root(), $this->directorySeparator() . 'vendor');
+ }
+
+ protected function relativeFromBaseOrPackage(string|FilePath $path): string
+ {
+ return $this->isPackage() ? $this->relativeFromPackage($path) : $this->relativeFrom($path);
+ }
+
+ protected function afterFirstSegmentInPath(string $path, string $directorySeparator): string
+ {
+ return mb_substr($path, Helper::strpos($path, $directorySeparator) + 1);
+ }
+
+ protected function getFirstSegmentInPath(string $path, string $directorySeparator): string
+ {
+ return mb_substr($path, 0, Helper::strpos($path, $directorySeparator));
+ }
+}
diff --git a/src/Support/Push/PushBatch.php b/src/Support/Push/PushBatch.php
new file mode 100644
index 0000000..336de3a
--- /dev/null
+++ b/src/Support/Push/PushBatch.php
@@ -0,0 +1,178 @@
+
+ * @phpstan-consistent-constructor
+ */
+class PushBatch implements Arrayable
+{
+ protected static int $max_pool_size = 5;
+ protected static int $max_chunk_size = 10;
+
+ protected int $total_pushed = 0;
+
+ public function __construct(
+ public readonly int $id,
+ public readonly PushPool $pool,
+ protected readonly int $total_pushable,
+ ) {
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(int $totalPushable): static
+ {
+ return new static(
+ time(),
+ PushPool::new(static::maxPoolSize(), static::maxChunkSize()),
+ $totalPushable,
+ );
+ }
+
+ /**
+ * Update the maximum chunks allowed in the pool.
+ * This determines the amount of chunks to be
+ * concurrently sent to Transl.
+ */
+ public static function resetDefaultMaxPoolAndChunkSizes(): void
+ {
+ static::$max_pool_size = 5;
+ static::$max_chunk_size = 10;
+ }
+
+ /**
+ * Update the maximum chunks allowed in the pool.
+ * This determines the amount of chunks to be
+ * concurrently sent to Transl.
+ */
+ public static function setMaxPoolSize(int $value): void
+ {
+ static::$max_pool_size = $value;
+ }
+
+ /**
+ * Update the maximum translation sets allowed per chunk.
+ */
+ public static function setMaxChunkSize(int $value): void
+ {
+ static::$max_chunk_size = $value;
+ }
+
+ /**
+ * The maximum chunks allowed in the pool.
+ * This determines the amount of chunks to be
+ * concurrently sent to Transl.
+ */
+ public static function maxPoolSize(): int
+ {
+ return static::$max_pool_size;
+ }
+
+ /**
+ * The maximum translation sets allowed per chunk.
+ */
+ public static function maxChunkSize(): int
+ {
+ return static::$max_chunk_size;
+ }
+
+ public function totalPushable(): int
+ {
+ return $this->total_pushable;
+ }
+
+ public function totalPushed(): int
+ {
+ return $this->total_pushed;
+ }
+
+ // public function isPending(): bool
+ // {
+ // return $this->total_pushed < $this->total_pushable;
+ // }
+
+ // public function isFinished(): bool
+ // {
+ // return $this->total_pushed >= $this->total_pushable;
+ // }
+
+ /**
+ * Fill the pool.
+ */
+ public function add(TranslationSet $translationSet): static
+ {
+ $this->pool->add($translationSet);
+
+ return $this;
+ }
+
+ /**
+ * Fill the pool and run a callback if full.
+ *
+ * @param callable(): void $callback
+ */
+ public function addUntilPoolFull(TranslationSet $translationSet, callable $callback): void
+ {
+ $this->add($translationSet);
+
+ if (!$this->pool->isFull()) {
+ return;
+ }
+
+ $this->drainPool($callback);
+ }
+
+ /**
+ * Run a callback if the pool is not empty.
+ *
+ * @param callable(): void $callback
+ */
+ public function ensurePoolDrained(callable $callback): void
+ {
+ if ($this->pool->isEmpty()) {
+ return;
+ }
+
+ $this->drainPool($callback);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+
+ 'max_pool_size' => static::maxPoolSize(),
+ 'max_chunk_size' => static::maxChunkSize(),
+
+ 'total_pushable' => $this->totalPushable(),
+ 'total_pushed' => $this->totalPushed(),
+
+ 'pool' => $this->pool->toArray(),
+ ];
+ }
+
+ /**
+ * Expects the provided callback to drain the pool.
+ *
+ * @param callable(): void $callback
+ */
+ protected function drainPool(callable $callback): void
+ {
+ $total = $this->pool->total();
+
+ $callback();
+
+ $this->total_pushed = $this->total_pushed + $total;
+ }
+}
diff --git a/src/Support/Push/PushChunk.php b/src/Support/Push/PushChunk.php
new file mode 100644
index 0000000..848a6dd
--- /dev/null
+++ b/src/Support/Push/PushChunk.php
@@ -0,0 +1,80 @@
+
+ * @phpstan-consistent-constructor
+ */
+class PushChunk implements Arrayable
+{
+ private int $size = 0;
+
+ /**
+ * @var TranslationSet[]
+ */
+ private array $translation_sets = [];
+
+ public function __construct(
+ public readonly int $number,
+ ) {
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(int $number): static
+ {
+ return new static($number);
+ }
+
+ /**
+ * Add a translation set to the chunk.
+ */
+ public function add(TranslationSet $translationSet): static
+ {
+ $this->translation_sets[] = $translationSet;
+
+ $this->size++;
+
+ return $this;
+ }
+
+ /**
+ * The current chunk size.
+ */
+ public function size(): int
+ {
+ return $this->size;
+ }
+
+ /**
+ * The translation sets of the chunk.
+ *
+ * @return TranslationSet[]
+ */
+ public function translationSets(): array
+ {
+ return $this->translation_sets;
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'number' => $this->number,
+ 'size' => $this->size(),
+ 'translation_sets' => array_map(
+ fn (TranslationSet $translationSet): array => $translationSet->toArray(),
+ $this->translationSets(),
+ ),
+ ];
+ }
+}
diff --git a/src/Support/Push/PushPool.php b/src/Support/Push/PushPool.php
new file mode 100644
index 0000000..8ea20c0
--- /dev/null
+++ b/src/Support/Push/PushPool.php
@@ -0,0 +1,205 @@
+
+ * @implements Arrayable
+ * @phpstan-consistent-constructor
+ */
+class PushPool implements Iterator, Countable, Arrayable
+{
+ private int $size = 0;
+
+ private int $total = 0;
+
+ private int $lastChunkNumber = 0;
+
+ /**
+ * @var array
+ */
+ private array $chunks = [];
+
+ public function __construct(
+ public readonly int $max_size,
+ public readonly int $max_chunk_size,
+ ) {
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(int $maxSize, int $maxChunkSize): static
+ {
+ return new static($maxSize, $maxChunkSize);
+ }
+
+ /**
+ * The current pool size.
+ */
+ public function size(): int
+ {
+ return $this->size;
+ }
+
+ /**
+ * The total stored translation sets.
+ */
+ public function total(): int
+ {
+ return $this->total;
+ }
+
+ /**
+ * The maximum translation sets the pool can hold.
+ */
+ public function maxTotal(): int
+ {
+ return $this->max_size * $this->max_chunk_size;
+ }
+
+ /**
+ * Determine whether the maximum chunks size
+ * has been reached or not.
+ */
+ public function isFull(): bool
+ {
+ return $this->total >= $this->maxTotal();
+ }
+
+ /**
+ * Determine whether their are defined
+ * chunks or not.
+ */
+ public function isEmpty(): bool
+ {
+ return $this->size <= 0;
+ }
+
+ /**
+ * Add a translation set to a chunk.
+ */
+ public function add(TranslationSet $translationSet): static
+ {
+ /** @var PushChunk|null $lastChunk */
+ $lastChunk = last($this->chunks) ?: null;
+
+ if (!$lastChunk || ($this->max_chunk_size <= $lastChunk->size())) {
+ $lastChunk = PushChunk::new($this->lastChunkNumber + 1);
+
+ $this->lastChunkNumber = $lastChunk->number;
+
+ $this->size++;
+ }
+
+ $lastChunk->add($translationSet);
+
+ $this->chunks[$lastChunk->number] = $lastChunk;
+
+ $this->total++;
+
+ return $this;
+ }
+
+ /**
+ * Retrieve the next chunk if any and remove it from the list.
+ */
+ public function drip(): ?PushChunk
+ {
+ $chunk = array_shift($this->chunks);
+
+ if ($chunk) {
+ $this->size--;
+
+ $this->total = $this->total - $chunk->size();
+ }
+
+ return $chunk;
+ }
+
+ // /**
+ // * Consume and remove all chunks.
+ // *
+ // * @param callable(PushChunk $chunk): void $callback
+ // */
+ // public function drain(callable $callback): void
+ // {
+ // while ($chunk = $this->drip()) {
+ // $callback($chunk);
+ // }
+ // }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'max_size' => $this->max_size,
+ 'max_chunk_size' => $this->max_chunk_size,
+ 'size' => $this->size(),
+ 'chunks' => array_map(fn (PushChunk $chunk): array => $chunk->toArray(), $this->chunks),
+ ];
+ }
+
+ /* Iterator methods
+ ------------------------------------------------*/
+
+ /**
+ * Rewind the Iterator to the first element.
+ */
+ public function rewind(): void
+ {
+ //
+ }
+
+ /**
+ * Checks if current position is valid.
+ */
+ public function valid(): bool
+ {
+ return !$this->isEmpty();
+ }
+
+ /**
+ * Return the current element.
+ */
+ public function current(): ?PushChunk
+ {
+ return $this->drip();
+ }
+
+ /**
+ * Move forward to next element.
+ */
+ public function next(): void
+ {
+ //
+ }
+
+ /**
+ * Return the key of the current element.
+ */
+ public function key(): mixed
+ {
+ return $this->size;
+ }
+
+ /* Countable methods
+ ------------------------------------------------*/
+
+ /**
+ * The pool size.
+ */
+ public function count(): int
+ {
+ return $this->size;
+ }
+}
diff --git a/src/Support/Reports/MissingTranslationKeys/MissingTranslationKey.php b/src/Support/Reports/MissingTranslationKeys/MissingTranslationKey.php
new file mode 100644
index 0000000..bdd16d7
--- /dev/null
+++ b/src/Support/Reports/MissingTranslationKeys/MissingTranslationKey.php
@@ -0,0 +1,45 @@
+
+ * @phpstan-consistent-constructor
+ */
+class MissingTranslationKey implements Arrayable
+{
+ public function __construct(
+ public readonly string $value,
+ public readonly array $replacements,
+ public readonly string $locale,
+ public readonly bool $fallback,
+ ) {
+ }
+
+ public static function new(string $value, array $replacements, string $locale, bool $fallback): static
+ {
+ return new static($value, $replacements, $locale, $fallback);
+ }
+
+ public function id(): string
+ {
+ return "{$this->locale}:{$this->value}";
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'value' => $this->value,
+ 'replacements' => $this->replacements,
+ 'locale' => $this->locale,
+ 'fallback' => $this->fallback,
+ ];
+ }
+}
diff --git a/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeyReport.php b/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeyReport.php
new file mode 100644
index 0000000..ea15019
--- /dev/null
+++ b/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeyReport.php
@@ -0,0 +1,51 @@
+
+ * @phpstan-consistent-constructor
+ */
+class MissingTranslationKeyReport implements Arrayable
+{
+ public function __construct(
+ public readonly ProjectConfiguration $project,
+ public readonly Branch $branch,
+ public readonly MissingTranslationKey $key,
+ ) {
+ }
+
+ public static function new(ProjectConfiguration $project, Branch $branch, MissingTranslationKey $key): static
+ {
+ return new static($project, $branch, $key);
+ }
+
+ public function group(): string
+ {
+ return "{$this->project->auth_key}:{$this->branch->name}";
+ }
+
+ public function id(): string
+ {
+ return "{$this->group()}:{$this->key->id()}";
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'key' => $this->key->toArray(),
+ 'project' => $this->project->toArray(),
+ 'branch' => $this->branch->toArray(),
+ ];
+ }
+}
diff --git a/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeys.php b/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeys.php
new file mode 100644
index 0000000..d28df96
--- /dev/null
+++ b/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeys.php
@@ -0,0 +1,241 @@
+
+ */
+ protected array $queue = [];
+
+ public function __construct()
+ {
+ register_shutdown_function([$this, 'report']);
+ }
+
+ /**
+ * Queue up a missing translation key to be reported.
+ */
+ public function add(
+ string $key,
+ array $replacements,
+ string $locale,
+ bool $fallback,
+ ProjectConfiguration|string|null $project = null,
+ Branch|string|null $branch = null,
+ ): static {
+ return $this->register(
+ MissingTranslationKey::new($key, $replacements, $locale, $fallback),
+ $project,
+ $branch,
+ );
+ }
+
+ /**
+ * Queue up a missing translation key object to be reported.
+ */
+ public function register(
+ MissingTranslationKey $key,
+ ProjectConfiguration|string|null $project = null,
+ Branch|string|null $branch = null,
+ ): static {
+ $report = $this->tryMakeReport($project, $branch, $key);
+
+ if (!$report) {
+ return $this;
+ }
+
+ return $this->queue($report);
+ }
+
+ /**
+ * Queue up a missing translation key report.
+ */
+ public function queue(MissingTranslationKeyReport $report): static
+ {
+ $this->queue[$report->id()] = $report;
+
+ return $this;
+ }
+
+ /**
+ * Set the missing translation key reports to report.
+ *
+ * @param array $queue
+ */
+ public function setQueue(array $queue): static
+ {
+ foreach ($queue as $report) {
+ $this->queue($report);
+ }
+
+ return $this;
+ }
+
+ public function flushQueue(): static
+ {
+ $this->queue = [];
+
+ return $this;
+ }
+
+ /**
+ * Retrieve the queued missing translation key reports.
+ *
+ * @return array
+ */
+ public function queued(): array
+ {
+ return $this->queue;
+ }
+
+ /**
+ * Send the queued missing translation key reports.
+ */
+ public function report(): void
+ {
+ $queued = $this->queued();
+
+ if (empty($queued)) {
+ return;
+ }
+
+ try {
+ app(SendMissingTranslationKeyReportAction::class)->execute($queued);
+ } catch (Throwable $th) {
+ if ($this->shouldFailSilently()) {
+ return;
+ }
+
+ throw $th;
+ } finally {
+ $this->flushQueue();
+ }
+ }
+
+ /* Helpers
+ ------------------------------------------------*/
+
+ protected function tryMakeReport(
+ ProjectConfiguration|string|null $project,
+ Branch|string|null $branch,
+ MissingTranslationKey $key,
+ ): ?MissingTranslationKeyReport {
+ try {
+ return $this->makeReport($project, $branch, $key);
+ } catch (TranslException $exception) {
+ if ($this->shouldFailSilently()) {
+ return null;
+ }
+
+ throw $exception;
+ }
+ }
+
+ protected function makeReport(
+ ProjectConfiguration|string|null $project,
+ Branch|string|null $branch,
+ MissingTranslationKey $key,
+ ): MissingTranslationKeyReport {
+ if (!$project) {
+ $project = $this->guessProject();
+ }
+
+ if (is_string($project)) {
+ $project = $this->findProject($project);
+ }
+
+ if (!$branch) {
+ $branch = $this->guessBranch($project);
+ }
+
+ if (is_string($branch)) {
+ $branch = $this->makeBranchFromString($branch);
+ }
+
+ if (!$project) {
+ throw CouldNotBuildReport::fromMissingProject($project);
+ }
+
+ if (!$branch) {
+ throw CouldNotBuildReport::fromMissingBranch($branch);
+ }
+
+ return MissingTranslationKeyReport::new($project, $branch, $key);
+ }
+
+ protected function findProject(string $project): ProjectConfiguration
+ {
+ $projects = Transl::config()->projects()->whereAuthKeyOrName($project);
+
+ if ($projects->isEmpty()) {
+ throw CouldNotDetermineProject::fromAuthKeyOrName($project);
+ }
+
+ if ($projects->count() > 1) {
+ throw MultipleProjectsFound::fromAuthKeyOrName($project);
+ }
+
+ return $projects->first();
+ }
+
+ protected function guessProject(): ?ProjectConfiguration
+ {
+ $projects = Transl::config()->projects();
+
+ if ($projects->count() > 1) {
+ throw MultipleProjectsFound::make();
+ }
+
+ return $projects->first();
+ }
+
+ protected function makeBranchFromString(string $branch): Branch
+ {
+ return Branch::asProvided($branch);
+ }
+
+ protected function guessBranch(?ProjectConfiguration $project): ?Branch
+ {
+ $branch = null;
+
+ if ($project && $project->options->branching->mirror_current_branch) {
+ $value = Git::currentBranchName();
+ $branch = $value ? Branch::asCurrent(trim($value)) : null;
+ }
+
+ if ($project && !$branch?->name) {
+ $value = $project->options->branching->default_branch_name ?: Git::defaultConfiguredBranchName();
+ $branch = $value ? Branch::asDefault(trim($value)) : null;
+ }
+
+ if (!$branch?->name) {
+ $branch = Branch::asFallback(Transl::FALLBACK_BRANCH_NAME);
+ }
+
+ return $branch;
+ }
+
+ protected function shouldFailSilently(): bool
+ {
+ return Transl::config()->reporting()->silently_discard_exceptions;
+ }
+}
diff --git a/src/Support/Reports/Reports.php b/src/Support/Reports/Reports.php
new file mode 100644
index 0000000..8b0912c
--- /dev/null
+++ b/src/Support/Reports/Reports.php
@@ -0,0 +1,18 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationLine implements Arrayable
+{
+ public function __construct(
+ /**
+ * The translation line key without the group.
+ * Example: `/lang/en/validation.php` -> `attributes.email.required`.
+ */
+ public readonly string $key,
+
+ /**
+ * The translation line value.
+ * The value that is to be translated.
+ */
+ public readonly null|string|int|float|bool $value,
+
+ /**
+ * Optional metadata about the translation line.
+ */
+ public readonly ?array $meta,
+ ) {
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(string $key, null|string|int|float|bool $value, ?array $meta): static
+ {
+ return new static($key, $value, $meta);
+ }
+
+ /**
+ * Named constructor accepting an undetermined `$value` type
+ * that will be standardized into one of the supported types
+ * (`null`|`string`|`int`|`float`|`bool`).
+ */
+ public static function make(string $key, mixed $value, ?array $meta): static
+ {
+ return static::from([
+ 'key' => $key,
+ 'value' => $value,
+ 'meta' => $meta,
+ ]);
+ }
+
+ /**
+ * Named constructor accepting an arbitrary array of values that
+ * will be used to contructor a new instance.
+ */
+ public static function from(array $properties): static
+ {
+ return static::new(...static::standardizePropertiesValues($properties));
+ }
+
+ /**
+ * Standardize the property values of the to be constructed
+ * instance.
+ */
+ protected static function standardizePropertiesValues(array $properties): array
+ {
+ [
+ 'key' => $key,
+ 'value' => $value,
+ 'meta' => $meta,
+ ] = $properties;
+
+ $meta = [
+ ...($meta ?: []),
+ 'original_value_type' => ($meta ?: [])['original_value_type'] ?? gettype($value),
+ ];
+
+ if (is_array($value) && empty($value)) {
+ $value = null;
+ }
+
+ if (!is_null($value) && !is_bool($value) && !is_numeric($value)) {
+ $value = (string) $value;
+ }
+
+ return [
+ 'key' => $key,
+ 'value' => $value,
+ 'meta' => $meta,
+ ];
+ }
+
+ /**
+ * Try, as best as possible with available metadata to reconstruct
+ * back the original value of the translation line.
+ */
+ public function potentialOriginalValue(): mixed
+ {
+ $originalValueType = ($this->meta ?: [])['original_value_type'] ?? null;
+
+ if ($originalValueType === 'array' && is_null($this->value)) {
+ return [];
+ }
+
+ return $this->value;
+ }
+
+ /**
+ * Convert the translation lines value into a string.
+ * Hopefully better than PHP's default type juggling.
+ */
+ public function valueAsString(): string
+ {
+ if ($this->value === true) {
+ return 'true';
+ }
+
+ if ($this->value === false) {
+ return 'false';
+ }
+
+ return (string) $this->value;
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'key' => $this->key,
+ 'value' => $this->value,
+ 'meta' => $this->meta,
+ ];
+ }
+}
diff --git a/src/Support/TranslationLineCollection.php b/src/Support/TranslationLineCollection.php
new file mode 100644
index 0000000..4aa8c18
--- /dev/null
+++ b/src/Support/TranslationLineCollection.php
@@ -0,0 +1,111 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationLineCollection extends Collection
+{
+ /**
+ * Create a new collection.
+ *
+ * @param Arrayable|iterable|null $items
+ * @return void
+ */
+ public function __construct($items = [])
+ {
+ $this->items = $this->arrayableItemsToTranslationLines($this->getArrayableItems($items));
+ }
+
+ /**
+ * Converts raw translation contents, after being
+ * "dottified" (could be by using `Arr::dot`),
+ * into TranslationLines.
+ *
+ * @param iterable $lines
+ */
+ public static function fromRawTranslationLines(iterable $lines): static
+ {
+ $lines = collect($lines)->map(static fn (mixed $value, string $key): TranslationLine => (
+ TranslationLine::make(
+ key: $key,
+ value: $value,
+ meta: null,
+ )
+ ));
+
+ return new static($lines);
+ }
+
+ /**
+ * Converts back to raw translation lines. These are
+ * translation contents that have been "dottified"
+ * (could be by using `Arr::dot`).
+ */
+ public function toRawTranslationLines(): array
+ {
+ return $this->reduce(static function (array $acc, TranslationLine $line): array {
+ $acc[$line->key] = $line->value;
+
+ return $acc;
+ }, []);
+ }
+
+ /**
+ * Converts back to raw translation lines while trying as
+ * best as posible to reconstruct back their original values.
+ */
+ public function toRawTranslationLinesWithPotentiallyOriginalValues(): array
+ {
+ return $this->reduce(static function (array $acc, TranslationLine $line): array {
+ $acc[$line->key] = $line->potentialOriginalValue();
+
+ return $acc;
+ }, []);
+ }
+
+ /**
+ * Get the collection of items as a plain array.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return array_map(static fn (TranslationLine $line): array => $line->toArray(), $this->items);
+ }
+
+ /**
+ * Converts items into TranslationLines.
+ *
+ * @param array $items
+ * @return array
+ */
+ protected function arrayableItemsToTranslationLines(array $items): array
+ {
+ return array_reduce($items, function (array $acc, array|TranslationLine $item): array {
+ if (!($item instanceof TranslationLine)) {
+ $item = $this->arrayableItemToTranslationLine($item);
+ }
+
+ $acc[] = $item;
+
+ return $acc;
+ }, []);
+ }
+
+ /**
+ * Converts an item into a TranslationLine.
+ */
+ protected function arrayableItemToTranslationLine(array $item): TranslationLine
+ {
+ return TranslationLine::new(...$item);
+ }
+}
diff --git a/src/Support/TranslationLinesDiffing.php b/src/Support/TranslationLinesDiffing.php
new file mode 100644
index 0000000..bceea97
--- /dev/null
+++ b/src/Support/TranslationLinesDiffing.php
@@ -0,0 +1,373 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationLinesDiffing implements Arrayable
+{
+ protected array $CACHE = [];
+
+ public function __construct(
+ protected readonly TranslationLineCollection $trackedLines,
+ protected readonly TranslationLineCollection $currentLines,
+ protected readonly TranslationLineCollection $incomingLines,
+ ) {
+ }
+
+ /**
+ * @param TranslationLineCollection $trackedLines The saved tracked lines.
+ * @param TranslationLineCollection $currentLines The currently untracked lines.
+ * @param TranslationLineCollection $incomingLines The incoming, remotely tracked lines.
+ */
+ public static function new(
+ TranslationLineCollection $trackedLines,
+ TranslationLineCollection $currentLines,
+ TranslationLineCollection $incomingLines,
+ ): static {
+ return new static($trackedLines, $currentLines, $incomingLines);
+ }
+
+ /**
+ * The saved tracked lines.
+ */
+ public function trackedLines(): TranslationLineCollection
+ {
+ return $this->trackedLines;
+ }
+
+ /**
+ * The currently untracked lines.
+ */
+ public function currentLines(): TranslationLineCollection
+ {
+ return $this->currentLines;
+ }
+
+ /**
+ * The incoming, remotely tracked lines.
+ */
+ public function incomingLines(): TranslationLineCollection
+ {
+ return $this->incomingLines;
+ }
+
+ /**
+ * Returns lines in `incoming` that differs from `current`.
+ */
+ public function changedLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ return $this->linesInTargetThatDiffersFromSource($this->incomingLines(), $this->currentLines());
+ });
+ }
+
+ /**
+ * Returns lines in `changed` that are missing in `added`.
+ */
+ public function updatedLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ return $this->linesInSourceThatAreMissingInTarget($this->changedLines(), $this->addedLines());
+ });
+ }
+
+ /**
+ * Returns lines in `incoming` that are the same in `current`.
+ */
+ public function sameLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ return $this->linesInSourceThatAreTheSameInTarget($this->incomingLines(), $this->currentLines());
+ });
+ }
+
+ /**
+ * Returns lines in `incoming` that are missing in `current`.
+ */
+ public function addedLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ return $this->linesInSourceThatAreMissingInTarget($this->incomingLines(), $this->currentLines());
+ });
+ }
+
+ /**
+ * Returns lines in `tracked` that are missing in `incoming`.
+ */
+ public function removedLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ return $this->linesInSourceThatAreMissingInTarget($this->trackedLines(), $this->incomingLines());
+ });
+ }
+
+ /**
+ * Returns lines between `tracked`, `current` & `incoming` that
+ * are no longer compatible and cannot be programmatically mergered;
+ * requiring the developer's input.
+ */
+ public function conflictingLines(): TranslationLineCollection
+ {
+ /**
+ * Heuristics (for same line changes):
+ * - current:same | incoming:same ---> ok
+ * - current:same | incoming:added ---> ok
+ * - current:same | incoming:updated ---> ok
+ * - current:same | incoming:removed ---> ok
+ *
+ * - current:added | incoming:same ---> ok
+ * - current:added | incoming:added ---> ok
+ * - current:added | incoming:updated ---> ok
+ * - current:added | incoming:removed ---> ok
+ *
+ * - current:updated | incoming:same ---> conflict
+ * - current:updated | incoming:added ---> conflict
+ * - current:updated | incoming:updated ---> conflict (if different changes)
+ * - current:updated | incoming:removed ---> conflict
+ *
+ * - current:removed | incoming:same ---> conflict
+ * - current:removed | incoming:added ---> conflict
+ * - current:removed | incoming:updated ---> conflict
+ * - current:removed | incoming:removed ---> ok
+ */
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ // $currentAdded = $this->linesInSourceThatAreMissingInTarget($this->currentLines(), $this->trackedLines());
+ $currentChanged = $this->linesInTargetThatDiffersFromSource($this->currentLines(), $this->trackedLines());
+
+ $current = [
+ // 'same' => $this->linesInSourceThatAreTheSameInTarget($this->currentLines(), $this->trackedLines()),
+ // 'added' => $currentAdded,
+ 'changed' => $currentChanged,
+ // 'updated' => $this->linesInSourceThatAreMissingInTarget($currentChanged, $currentAdded),
+ 'removed' => $this->linesInSourceThatAreMissingInTarget($this->trackedLines(), $this->currentLines()),
+ ];
+ $incoming = [
+ // 'same' => $this->sameLines(),
+ // 'added' => $this->addedLines(),
+ 'changed' => $this->changedLines(),
+ // 'updated' => $this->updatedLines(),
+ 'removed' => $this->removedLines(),
+ ];
+
+ $conflicting = TranslationLineCollection::make();
+
+ if ($current['changed']->isNotEmpty()) {
+ $conflicting->push(...$this->linesInSourceThatExistsInTarget($incoming['changed'], $current['changed']));
+ $conflicting->push(...$this->linesInSourceThatExistsInTarget($incoming['removed'], $current['changed']));
+ }
+
+ if ($current['removed']->isNotEmpty()) {
+ $conflicting->push(...$this->linesInSourceThatAreMissingInTarget($current['removed'], $incoming['removed']));
+ }
+
+ return $conflicting;
+ });
+ }
+
+ /**
+ * Returns lines in `changed` that are missing in `conflicting`.
+ */
+ public function nonConflictingLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ return $this->linesInSourceThatAreMissingInTarget(
+ $this->changedLines(),
+ $this->conflictingLines(),
+ );
+ });
+ }
+
+ /**
+ * Returns lines from `same` merged with lines from `nonConflicting`.
+ */
+ public function safeLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ return TranslationLineCollection::fromRawTranslationLines([
+ ...$this->sameLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ...$this->nonConflictingLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ]);
+ });
+ }
+
+ /**
+ * Returns lines from `current` merged with lines from `safe`
+ * minus non conflicting removed lines.
+ */
+ public function mergeableLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ $lines = collect([
+ ...$this->currentLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ...$this->safeLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ]);
+
+ $conflictingKeys = array_keys($this->conflictingLines()->toRawTranslationLinesWithPotentiallyOriginalValues());
+
+ $nonConflictingRemoved = Arr::except($this->removedLines()->toRawTranslationLinesWithPotentiallyOriginalValues(), $conflictingKeys);
+
+ $lines->forget(array_keys($nonConflictingRemoved));
+
+ return TranslationLineCollection::fromRawTranslationLines($lines);
+ });
+ }
+
+ // /**
+ // * Returns merged lines between tracked & current & incoming
+ // * with the tracked lines taking precedence over fall.
+ // */
+ // public function favorTrackedLines(): TranslationLineCollection
+ // {
+ // return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ // return TranslationLineCollection::fromRawTranslationLines([
+ // ...$this->trackedLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ // // Lines added in current
+ // ...$this->linesInSourceThatAreMissingInTarget(
+ // $this->currentLines(),
+ // $this->trackedLines(),
+ // )->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ // ...$this->addedLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ // ]);
+ // });
+ // }
+
+ /**
+ * Returns merged lines between current & incoming with
+ * the current lines taking precedence.
+ */
+ public function favorCurrentLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ $lines = collect([
+ ...$this->currentLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ...$this->addedLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ]);
+
+ $currentRemoved = $this->linesInSourceThatAreMissingInTarget($this->trackedLines(), $this->currentLines());
+
+ $lines->forget(array_keys($currentRemoved->toRawTranslationLinesWithPotentiallyOriginalValues()));
+
+ return TranslationLineCollection::fromRawTranslationLines($lines);
+ });
+ }
+
+ /**
+ * Returns merged lines between current & incoming with
+ * the incoming lines taking precedence.
+ */
+ public function favorIncomingLines(): TranslationLineCollection
+ {
+ return $this->cache(__METHOD__, function (): TranslationLineCollection {
+ $lines = collect([
+ ...$this->currentLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ...$this->updatedLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ...$this->addedLines()->toRawTranslationLinesWithPotentiallyOriginalValues(),
+ ]);
+
+ $lines->forget(array_keys($this->removedLines()->toRawTranslationLinesWithPotentiallyOriginalValues()));
+
+ return TranslationLineCollection::fromRawTranslationLines($lines);
+ });
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'tracked_lines' => $this->trackedLines()->toArray(),
+ 'current_lines' => $this->currentLines()->toArray(),
+ 'incoming_lines' => $this->incomingLines()->toArray(),
+ ];
+ }
+
+ // protected function linesInSourceThatChangedInTarget(
+ // TranslationLineCollection $source,
+ // TranslationLineCollection $target,
+ // ): TranslationLineCollection {
+ // $source = collect($source->toRawTranslationLinesWithPotentiallyOriginalValues());
+ // $target = collect($target->toRawTranslationLinesWithPotentiallyOriginalValues());
+
+ // return TranslationLineCollection::fromRawTranslationLines($source->diffAssoc($target));
+ // }
+
+ protected function linesInTargetThatDiffersFromSource(
+ TranslationLineCollection $target,
+ TranslationLineCollection $source,
+ ): TranslationLineCollection {
+ $target = collect($target->toRawTranslationLinesWithPotentiallyOriginalValues());
+ $source = collect($source->toRawTranslationLinesWithPotentiallyOriginalValues());
+
+ /**
+ * Does not handle empty array values.
+ * Throws "Array to string conversion".
+ */
+ // return TranslationLineCollection::fromRawTranslationLines($target->diffAssoc($source));
+
+ $lines = $target->filter(static function (mixed $value, string $key) use ($source): bool {
+ return $value !== $source->get($key);
+ });
+
+ return TranslationLineCollection::fromRawTranslationLines($lines);
+ }
+
+ protected function linesInSourceThatAreTheSameInTarget(
+ TranslationLineCollection $source,
+ TranslationLineCollection $target,
+ ): TranslationLineCollection {
+ $source = collect($source->toRawTranslationLinesWithPotentiallyOriginalValues());
+ $target = collect($target->toRawTranslationLinesWithPotentiallyOriginalValues());
+
+ /**
+ * Does not handle empty array values.
+ * Throws "Array to string conversion".
+ */
+ // return TranslationLineCollection::fromRawTranslationLines($source->intersectAssoc($target));
+
+ $lines = $source->filter(static function (mixed $value, string $key) use ($target): bool {
+ return $value === $target->get($key);
+ });
+
+ return TranslationLineCollection::fromRawTranslationLines($lines);
+ }
+
+ protected function linesInSourceThatAreMissingInTarget(
+ TranslationLineCollection $source,
+ TranslationLineCollection $target,
+ ): TranslationLineCollection {
+ $source = collect($source->toRawTranslationLinesWithPotentiallyOriginalValues());
+ $target = collect($target->toRawTranslationLinesWithPotentiallyOriginalValues());
+
+ return TranslationLineCollection::fromRawTranslationLines($source->diffKeys($target));
+ }
+
+ protected function linesInSourceThatExistsInTarget(
+ TranslationLineCollection $source,
+ TranslationLineCollection $target,
+ ): TranslationLineCollection {
+ $source = collect($source->toRawTranslationLinesWithPotentiallyOriginalValues());
+ $target = collect($target->toRawTranslationLinesWithPotentiallyOriginalValues());
+
+ return TranslationLineCollection::fromRawTranslationLines($source->intersectByKeys($target));
+ }
+
+ protected function cache(string $key, Closure $callback): TranslationLineCollection
+ {
+ if (!isset($this->CACHE[$key])) {
+ $this->CACHE[$key] = $callback();
+ }
+
+ return $this->CACHE[$key];
+ }
+}
diff --git a/src/Support/TranslationSet.php b/src/Support/TranslationSet.php
new file mode 100644
index 0000000..1c6d61a
--- /dev/null
+++ b/src/Support/TranslationSet.php
@@ -0,0 +1,152 @@
+
+ * @phpstan-consistent-constructor
+ */
+class TranslationSet implements Arrayable
+{
+ public function __construct(
+ public readonly string $locale,
+ public readonly ?string $group,
+ public readonly ?string $namespace,
+ public readonly TranslationLineCollection $lines,
+ public readonly ?array $meta,
+ ) {
+ }
+
+ /**
+ * Named constructor.
+ */
+ public static function new(
+ string $locale,
+ ?string $group,
+ ?string $namespace,
+ TranslationLineCollection $lines,
+ ?array $meta,
+ ): static {
+ return app(static::class, [
+ 'locale' => $locale,
+ 'group' => $group,
+ 'namespace' => $namespace,
+ 'lines' => $lines,
+ 'meta' => $meta,
+ ]);
+ }
+
+ /**
+ * Named constructor accepting an arbitrary array of values that
+ * will be used to contructor a new instance.
+ */
+ public static function from(array $properties): static
+ {
+ if (!($properties['lines'] instanceof TranslationLineCollection)) {
+ /** @var array $properties['lines'] */
+ $properties['lines'] = TranslationLineCollection::make($properties['lines']);
+ }
+
+ return static::new(...$properties);
+ }
+
+ /**
+ * Constructs a new instance of `TranslationLinesDiffing`
+ * with the lines of the current instance as the incoming lines
+ * from which to differenciate.
+ */
+ public function diff(
+ TranslationLineCollection|TranslationSet $trackedLines,
+ TranslationLineCollection|TranslationSet $currentLines,
+ ): TranslationLinesDiffing {
+ if ($trackedLines instanceof TranslationSet) {
+ $trackedLines = $trackedLines->lines;
+ }
+
+ if ($currentLines instanceof TranslationSet) {
+ $currentLines = $currentLines->lines;
+ }
+
+ return TranslationLinesDiffing::new(
+ trackedLines: $trackedLines,
+ currentLines: $currentLines,
+ incomingLines: $this->lines,
+ );
+ }
+
+ /**
+ * tl;dr note: Having multiple JSON files for the same locale = BAD
+ * but expected behavior.
+ *
+ * Longer note: Having multiple JSON files for the same locale,
+ * when using the provided `LocalFilesDriver` driver,
+ * will result in a single and same tracking key for all
+ * JSON files; no matter nested or not.
+ *
+ * This is because all JSON files result in a null group
+ * and a null namespace. Thus, the only possible differentiator
+ * is the locale, but they would all have the same and
+ * therefore result in the same tracking key.
+ *
+ * This is the expected behavior as that's exacly how Laravel's
+ * `vendor/laravel/framework/src/Illuminate/Translation/FileLoader.php`
+ * handles JSON translation files, with the difference that `*` is the
+ * value of the group and the namespace.
+ *
+ * When looping through the available translation files the lines
+ * attached to the resulting tracking key should in theory be that of
+ * the last JSON file in the loop.
+ */
+ public function trackingKey(): string
+ {
+ $key = "locales/{$this->locale}";
+
+ if ($this->group) {
+ $key = "groups/{$this->group}/{$key}";
+ }
+
+ if ($this->namespace) {
+ $key = "namespaces/{$this->namespace}/{$key}";
+ }
+
+ return $key;
+ }
+
+ /**
+ * The key used in Laravel translation helper functions
+ * like `__` or `trans`.
+ *
+ * Will return an empty string when the translation set
+ * has no group and no namespace.
+ */
+ public function translationKey(): string
+ {
+ $key = (string) $this->group;
+
+ if ($this->group && $this->namespace) {
+ $key = "{$this->namespace}::{$key}";
+ }
+
+ return $key;
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'locale' => $this->locale,
+ 'group' => $this->group,
+ 'namespace' => $this->namespace,
+ 'lines' => $this->lines->toArray(),
+ 'meta' => $this->meta,
+ ];
+ }
+}
diff --git a/src/Support/Versions.php b/src/Support/Versions.php
new file mode 100644
index 0000000..c8f4cba
--- /dev/null
+++ b/src/Support/Versions.php
@@ -0,0 +1,78 @@
+
+ * @phpstan-consistent-constructor
+ */
+class Versions implements Arrayable
+{
+ use Instanciable;
+
+ public function __construct(
+ public readonly string $php,
+ public readonly string $laravel,
+ public readonly string $package,
+ ) {
+ }
+
+ public static function new(string $php, string $laravel, string $package): static
+ {
+ return new static($php, $laravel, $package);
+ }
+
+ public static function make(): static
+ {
+ return static::new(
+ static::phpVersion(),
+ static::laravelVersion(),
+ static::packageVersion(),
+ );
+ }
+
+ public static function instance(): static
+ {
+ if (!static::hasInstance()) {
+ static::setInstance(static::make());
+ }
+
+ // @phpstan-ignore-next-line
+ return static::getInstance();
+ }
+
+ public static function phpVersion(): string
+ {
+ return PHP_VERSION;
+ }
+
+ public static function laravelVersion(): string
+ {
+ return Application::VERSION;
+ }
+
+ public static function packageVersion(): string
+ {
+ return (string) InstalledVersions::getVersion(Transl::PACKAGE_NAME);
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'php' => $this->php,
+ 'laravel' => $this->laravel,
+ 'package' => $this->package,
+ ];
+ }
+}
diff --git a/src/Transl.php b/src/Transl.php
new file mode 100644
index 0000000..c039e49
--- /dev/null
+++ b/src/Transl.php
@@ -0,0 +1,57 @@
+
+ */
+class Transl implements Arrayable
+{
+ public const PACKAGE_NAME = 'transl-me/laravel-transl';
+ public const FALLBACK_BRANCH_NAME = 'main';
+
+ public function versions(): Versions
+ {
+ return Versions::instance();
+ }
+
+ public function config(?array $config = null): Configuration
+ {
+ return Configuration::instance($config ?? config('transl'));
+ }
+
+ public function commands(): Commands
+ {
+ return Commands::instance();
+ }
+
+ public function reports(): Reports
+ {
+ return Reports::instance();
+ }
+
+ public function api(): Api
+ {
+ return Api::instance();
+ }
+
+ /**
+ * Get the instance as an array.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'versions' => $this->versions()->toArray(),
+ 'config' => $this->config()->toArray(),
+ ];
+ }
+}
diff --git a/src/TranslServiceProvider.php b/src/TranslServiceProvider.php
new file mode 100644
index 0000000..512d246
--- /dev/null
+++ b/src/TranslServiceProvider.php
@@ -0,0 +1,138 @@
+name('transl')
+ ->hasConfigFile()
+ ->hasCommands([
+ TranslPushCommand::class,
+ TranslPullCommand::class,
+ TranslSynchCommand::class,
+ TranslInitCommand::class,
+ TranslAnalyseCommand::class,
+ ]);
+ }
+
+ public function packageRegistered(): void
+ {
+ $this->app->singleton(MissingTranslationKeys::class);
+
+ if (!$this->shouldPatchTranslator()) {
+ return;
+ }
+
+ $this->patchTranslator();
+ }
+
+ public function packageBooted(): void
+ {
+ Lang::resolved(function (TranslatorContract $translator): void {
+ if (!$this->shouldHandleMissingTranslationKeys()) {
+ return;
+ }
+
+ if (!$this->canHandleMissingTranslationKeys($translator)) {
+ return;
+ }
+
+ $this->handleMissingTranslationKeys($translator);
+ });
+ }
+
+ protected function shouldPatchTranslator(): bool
+ {
+ return version_compare($this->app->version(), '10.43.0') < 0;
+ }
+
+ protected function shouldHandleMissingTranslationKeys(): bool
+ {
+ return Transl::config()->reporting()->should_report_missing_translation_keys;
+ }
+
+ protected function canHandleMissingTranslationKeys(TranslatorContract $translator): bool
+ {
+ /**
+ * - Laravel v10.33.0 (https://github.com/laravel/framework/releases/tag/v10.33.0)
+ * - Docs: https://laravel.com/docs/10.x/localization#handling-missing-translation-strings.
+ */
+ if (!method_exists($translator, 'handleMissingKeysUsing')) {
+ return false;
+ }
+
+ return (bool) Transl::config()->reporting()->report_missing_translation_keys_using;
+ }
+
+ protected function patchTranslator(): void
+ {
+ /**
+ * @see vendor/laravel/framework/src/Illuminate/Translation/TranslationServiceProvider.php
+ */
+ $this->app->singleton('translator', function ($app) {
+ $loader = $app['translation.loader'];
+
+ // When registering the translator component, we'll need to set the default
+ // locale as well as the fallback locale. So, we'll grab the application
+ // configuration so we can easily get both of these values from there.
+ $locale = $app->getLocale();
+
+ $trans = new PatchedTranslator($loader, $locale);
+
+ $trans->setFallback($app->getFallbackLocale());
+
+ return $trans;
+ });
+ }
+
+ protected function handleMissingTranslationKeys(TranslatorContract $translator): void
+ {
+ try {
+ Lang::handleMissingKeysUsing(function (...$args): mixed {
+ $use = Transl::config()->reporting()->report_missing_translation_keys_using;
+ $method = method_exists($use, 'execute') ? 'execute' : '__invoke';
+
+ return app($use)->{$method}(...$args);
+ });
+ } catch (Throwable $th) {
+ if (Transl::config()->reporting()->silently_discard_exceptions) {
+ return;
+ }
+
+ throw $th;
+ }
+ }
+}
diff --git "a/tests/.pest/snapshots/src/Actions/Commands/PullCommandActionTest/_base__\342\206\222_it_pulles_the_translation_sets_from_Transl___applies_the_correct_formatting.snap" "b/tests/.pest/snapshots/src/Actions/Commands/PullCommandActionTest/_base__\342\206\222_it_pulles_the_translation_sets_from_Transl___applies_the_correct_formatting.snap"
new file mode 100644
index 0000000..441edbc
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/Commands/PullCommandActionTest/_base__\342\206\222_it_pulles_the_translation_sets_from_Transl___applies_the_correct_formatting.snap"
@@ -0,0 +1,67 @@
+[
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en.json",
+ "contents": "{\n \"Hello\": \"[JSON] Hello!\",\n \"pages.dashboard.nav.users.billing\": \"[JSON] overriden 'pages.dashboard.nav.users.billing'!\",\n \"pages.dashboard.nav.users.logout\": \"[JSON] overriden 'pages.dashboard.nav.users.logout'!\",\n \"null\": null,\n \"string\": \"hello\",\n \"true\": true,\n \"false\": false,\n \"int\": 123,\n \"float\": 123.123,\n \"string_null\": \"null\",\n \"string_true\": \"true\",\n \"string_false\": \"false\",\n \"string_int\": \"123\",\n \"string_float\": \"123.123\",\n \"string_empty\": \"\",\n \"array_empty\": []\n}",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/auth.php",
+ "contents": " 'These credentials do not match our records.',\n 'password' => '[Modified] - The provided password is incorrect.',\n 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',\n 'failed_bis' => 'These credentials do not match our records.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/email.php",
+ "contents": " 'contact@example.com',\n 'Help' => 'help@example.com',\n 'Sales' => 'sales@example.com',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/flash.php",
+ "contents": " [\n 'creation' => [\n 'success' => 'Some success message.',\n 'error' => 'Some error message.',\n ],\n ],\n 'organization' => [],\n 'billing' => null,\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'profile' => 'Profile',\n 'billing' => 'Billing',\n 'password' => 'Password',\n 'logout' => 'Log out',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/value_types.php",
+ "contents": " null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'string_null' => 'null',\n 'string_true' => 'true',\n 'string_false' => 'false',\n 'string_int' => '123',\n 'string_float' => '123.123',\n 'string_empty' => '',\n 'array_filled_null' => [\n null,\n ],\n 'array_filled_string' => [\n 'hello',\n ],\n 'array_filled_true' => [\n true,\n ],\n 'array_filled_false' => [\n false,\n ],\n 'array_filled_int' => [\n 123,\n ],\n 'array_filled_float' => [\n 123.123,\n ],\n 'array_empty' => [],\n 'multi_array_filled_null' => [\n 'hey' => null,\n ],\n 'multi_array_filled_string' => [\n 'hey' => 'hello',\n ],\n 'multi_array_filled_true' => [\n 'hey' => true,\n ],\n 'multi_array_filled_false' => [\n 'hey' => false,\n ],\n 'multi_array_filled_int' => [\n 'hey' => 123,\n ],\n 'multi_array_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Collection_filled_null' => [\n null,\n ],\n 'Collection_filled_string' => [\n 'hello',\n ],\n 'Collection_filled_true' => [\n true,\n ],\n 'Collection_filled_false' => [\n false,\n ],\n 'Collection_filled_int' => [\n 123,\n ],\n 'Collection_filled_float' => [\n 123.123,\n ],\n 'Collection_empty' => [],\n 'multi_Collection_filled_null' => [\n 'hey' => null,\n ],\n 'multi_Collection_filled_string' => [\n 'hey' => 'hello',\n ],\n 'multi_Collection_filled_true' => [\n 'hey' => true,\n ],\n 'multi_Collection_filled_false' => [\n 'hey' => false,\n ],\n 'multi_Collection_filled_int' => [\n 'hey' => 123,\n ],\n 'multi_Collection_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Stringable' => 'I\\'m Stringable',\n 'stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_null' => null,\n 'Closure_string' => 'hello',\n 'Closure_true' => true,\n 'Closure_false' => false,\n 'Closure_int' => 123,\n 'Closure_float' => 123.123,\n 'Closure_string_null' => 'null',\n 'Closure_string_true' => 'true',\n 'Closure_string_false' => 'false',\n 'Closure_string_int' => '123',\n 'Closure_string_float' => '123.123',\n 'Closure_string_empty' => '',\n 'Closure_Collection_filled_null' => [\n null,\n ],\n 'Closure_Collection_filled_string' => [\n 'hello',\n ],\n 'Closure_Collection_filled_true' => [\n true,\n ],\n 'Closure_Collection_filled_false' => [\n false,\n ],\n 'Closure_Collection_filled_int' => [\n 123,\n ],\n 'Closure_Collection_filled_float' => [\n 123.123,\n ],\n 'Closure_Collection_empty' => [],\n 'Closure_multi_Collection_filled_null' => [\n 'hey' => null,\n ],\n 'Closure_multi_Collection_filled_string' => [\n 'hey' => 'hello',\n ],\n 'Closure_multi_Collection_filled_true' => [\n 'hey' => true,\n ],\n 'Closure_multi_Collection_filled_false' => [\n 'hey' => false,\n ],\n 'Closure_multi_Collection_filled_int' => [\n 'hey' => 123,\n ],\n 'Closure_multi_Collection_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Closure_Stringable' => 'I\\'m Stringable',\n 'Closure_stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_Closure_stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_with_params' => null,\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/fr.json",
+ "contents": "{\n \"Hello\": \"[FR][JSON] Hello!\",\n \"pages.dashboard.nav.users.billing\": \"[FR][JSON] overriden 'pages.dashboard.nav.users.billing'!\",\n \"pages.dashboard.nav.users.logout\": \"[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!\",\n \"pages\/dashboard\/nav.users.logout\": \"[FR][JSON][bis] overriden 'pages.dashboard.nav.users.logout'!\"\n}",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/fr\/auth.php",
+ "contents": " '[FR] These credentials do not match our records.',\n 'password' => '[FR][Modified] - The provided password is incorrect.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/fr\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'profile' => '[FR] Profile',\n 'billing' => '[FR] Billing',\n 'password' => '[FR] Password',\n 'logout' => '[FR] Log out',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/en\/auth.php",
+ "contents": " 'These credentials do not match our records.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/en\/example.php",
+ "contents": " [\n 'attribute_1' => '__str__value_1',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/en\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'password' => '[Vendor] - Password',\n 'password_bis' => '[Vendor] - Password_bis',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/fr\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'password' => '[Vendor][FR] - Password',\n 'password_bis' => '[Vendor][FR] - Password_bis',\n ],\n];\n",
+ "lock": false
+ }
+]
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/Commands/PullCommandActionTest/_merge___conflict_resolution__\342\206\222_it_can_save_previously_untracked_translation_sets.snap" "b/tests/.pest/snapshots/src/Actions/Commands/PullCommandActionTest/_merge___conflict_resolution__\342\206\222_it_can_save_previously_untracked_translation_sets.snap"
new file mode 100644
index 0000000..441edbc
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/Commands/PullCommandActionTest/_merge___conflict_resolution__\342\206\222_it_can_save_previously_untracked_translation_sets.snap"
@@ -0,0 +1,67 @@
+[
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en.json",
+ "contents": "{\n \"Hello\": \"[JSON] Hello!\",\n \"pages.dashboard.nav.users.billing\": \"[JSON] overriden 'pages.dashboard.nav.users.billing'!\",\n \"pages.dashboard.nav.users.logout\": \"[JSON] overriden 'pages.dashboard.nav.users.logout'!\",\n \"null\": null,\n \"string\": \"hello\",\n \"true\": true,\n \"false\": false,\n \"int\": 123,\n \"float\": 123.123,\n \"string_null\": \"null\",\n \"string_true\": \"true\",\n \"string_false\": \"false\",\n \"string_int\": \"123\",\n \"string_float\": \"123.123\",\n \"string_empty\": \"\",\n \"array_empty\": []\n}",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/auth.php",
+ "contents": " 'These credentials do not match our records.',\n 'password' => '[Modified] - The provided password is incorrect.',\n 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',\n 'failed_bis' => 'These credentials do not match our records.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/email.php",
+ "contents": " 'contact@example.com',\n 'Help' => 'help@example.com',\n 'Sales' => 'sales@example.com',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/flash.php",
+ "contents": " [\n 'creation' => [\n 'success' => 'Some success message.',\n 'error' => 'Some error message.',\n ],\n ],\n 'organization' => [],\n 'billing' => null,\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'profile' => 'Profile',\n 'billing' => 'Billing',\n 'password' => 'Password',\n 'logout' => 'Log out',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/en\/value_types.php",
+ "contents": " null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'string_null' => 'null',\n 'string_true' => 'true',\n 'string_false' => 'false',\n 'string_int' => '123',\n 'string_float' => '123.123',\n 'string_empty' => '',\n 'array_filled_null' => [\n null,\n ],\n 'array_filled_string' => [\n 'hello',\n ],\n 'array_filled_true' => [\n true,\n ],\n 'array_filled_false' => [\n false,\n ],\n 'array_filled_int' => [\n 123,\n ],\n 'array_filled_float' => [\n 123.123,\n ],\n 'array_empty' => [],\n 'multi_array_filled_null' => [\n 'hey' => null,\n ],\n 'multi_array_filled_string' => [\n 'hey' => 'hello',\n ],\n 'multi_array_filled_true' => [\n 'hey' => true,\n ],\n 'multi_array_filled_false' => [\n 'hey' => false,\n ],\n 'multi_array_filled_int' => [\n 'hey' => 123,\n ],\n 'multi_array_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Collection_filled_null' => [\n null,\n ],\n 'Collection_filled_string' => [\n 'hello',\n ],\n 'Collection_filled_true' => [\n true,\n ],\n 'Collection_filled_false' => [\n false,\n ],\n 'Collection_filled_int' => [\n 123,\n ],\n 'Collection_filled_float' => [\n 123.123,\n ],\n 'Collection_empty' => [],\n 'multi_Collection_filled_null' => [\n 'hey' => null,\n ],\n 'multi_Collection_filled_string' => [\n 'hey' => 'hello',\n ],\n 'multi_Collection_filled_true' => [\n 'hey' => true,\n ],\n 'multi_Collection_filled_false' => [\n 'hey' => false,\n ],\n 'multi_Collection_filled_int' => [\n 'hey' => 123,\n ],\n 'multi_Collection_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Stringable' => 'I\\'m Stringable',\n 'stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_null' => null,\n 'Closure_string' => 'hello',\n 'Closure_true' => true,\n 'Closure_false' => false,\n 'Closure_int' => 123,\n 'Closure_float' => 123.123,\n 'Closure_string_null' => 'null',\n 'Closure_string_true' => 'true',\n 'Closure_string_false' => 'false',\n 'Closure_string_int' => '123',\n 'Closure_string_float' => '123.123',\n 'Closure_string_empty' => '',\n 'Closure_Collection_filled_null' => [\n null,\n ],\n 'Closure_Collection_filled_string' => [\n 'hello',\n ],\n 'Closure_Collection_filled_true' => [\n true,\n ],\n 'Closure_Collection_filled_false' => [\n false,\n ],\n 'Closure_Collection_filled_int' => [\n 123,\n ],\n 'Closure_Collection_filled_float' => [\n 123.123,\n ],\n 'Closure_Collection_empty' => [],\n 'Closure_multi_Collection_filled_null' => [\n 'hey' => null,\n ],\n 'Closure_multi_Collection_filled_string' => [\n 'hey' => 'hello',\n ],\n 'Closure_multi_Collection_filled_true' => [\n 'hey' => true,\n ],\n 'Closure_multi_Collection_filled_false' => [\n 'hey' => false,\n ],\n 'Closure_multi_Collection_filled_int' => [\n 'hey' => 123,\n ],\n 'Closure_multi_Collection_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Closure_Stringable' => 'I\\'m Stringable',\n 'Closure_stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_Closure_stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_with_params' => null,\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/fr.json",
+ "contents": "{\n \"Hello\": \"[FR][JSON] Hello!\",\n \"pages.dashboard.nav.users.billing\": \"[FR][JSON] overriden 'pages.dashboard.nav.users.billing'!\",\n \"pages.dashboard.nav.users.logout\": \"[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!\",\n \"pages\/dashboard\/nav.users.logout\": \"[FR][JSON][bis] overriden 'pages.dashboard.nav.users.logout'!\"\n}",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/fr\/auth.php",
+ "contents": " '[FR] These credentials do not match our records.',\n 'password' => '[FR][Modified] - The provided password is incorrect.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/fr\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'profile' => '[FR] Profile',\n 'billing' => '[FR] Billing',\n 'password' => '[FR] Password',\n 'logout' => '[FR] Log out',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/en\/auth.php",
+ "contents": " 'These credentials do not match our records.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/en\/example.php",
+ "contents": " [\n 'attribute_1' => '__str__value_1',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/en\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'password' => '[Vendor] - Password',\n 'password_bis' => '[Vendor] - Password_bis',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/.to-delete\/PullCommandActionTest\/lang\/vendor\/some_package\/fr\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'password' => '[Vendor][FR] - Password',\n 'password_bis' => '[Vendor][FR] - Password_bis',\n ],\n];\n",
+ "lock": false
+ }
+]
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en_____JSON_file_.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en_____JSON_file_.snap"
new file mode 100644
index 0000000..0c8c4df
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en_____JSON_file_.snap"
@@ -0,0 +1,26 @@
+{
+ "Hello": "[JSON] Hello!",
+ "pages.dashboard.nav.users.billing": "[JSON] overriden 'pages.dashboard.nav.users.billing'!",
+ "pages": {
+ "dashboard": {
+ "nav": {
+ "users": {
+ "logout": "[JSON] overriden 'pages.dashboard.nav.users.logout'!"
+ }
+ }
+ }
+ },
+ "null": null,
+ "string": "hello",
+ "true": true,
+ "false": false,
+ "int": 123,
+ "float": 123.123,
+ "string_null": "null",
+ "string_true": "true",
+ "string_false": "false",
+ "string_int": "123",
+ "string_float": "123.123",
+ "string_empty": "",
+ "array_empty": []
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____auth.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____auth.snap"
new file mode 100644
index 0000000..632d4bf
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____auth.snap"
@@ -0,0 +1,6 @@
+{
+ "failed": "These credentials do not match our records.",
+ "password": "[Modified] - The provided password is incorrect.",
+ "throttle": "Too many login attempts. Please try again in :seconds seconds.",
+ "failed_bis": "These credentials do not match our records."
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____email.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____email.snap"
new file mode 100644
index 0000000..3ee4793
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____email.snap"
@@ -0,0 +1,5 @@
+{
+ "Contact": "contact@example.com",
+ "Help": "help@example.com",
+ "Sales": "sales@example.com"
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____flash.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____flash.snap"
new file mode 100644
index 0000000..bbe3605
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____flash.snap"
@@ -0,0 +1,10 @@
+{
+ "user": {
+ "creation": {
+ "success": "Some success message.",
+ "error": "Some error message."
+ }
+ },
+ "organization": [],
+ "billing": null
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____pages_dashboard_nav.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____pages_dashboard_nav.snap"
new file mode 100644
index 0000000..c1aafb2
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____pages_dashboard_nav.snap"
@@ -0,0 +1,8 @@
+{
+ "users": {
+ "profile": "Profile",
+ "billing": "Billing",
+ "password": "Password",
+ "logout": "Log out"
+ }
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__auth.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__auth.snap"
new file mode 100644
index 0000000..8706281
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__auth.snap"
@@ -0,0 +1,3 @@
+{
+ "failed_bis_from_vendor": "These credentials do not match our records."
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__example.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__example.snap"
new file mode 100644
index 0000000..643062f
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__example.snap"
@@ -0,0 +1,5 @@
+{
+ "attributes": {
+ "attribute_1": "__str__value_1"
+ }
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__pages_dashboard_nav.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__pages_dashboard_nav.snap"
new file mode 100644
index 0000000..7ff132a
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____some__package__pages_dashboard_nav.snap"
@@ -0,0 +1,6 @@
+{
+ "users": {
+ "password": "[Vendor] - Password",
+ "password_bis": "[Vendor] - Password_bis"
+ }
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____value__types.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____value__types.snap"
new file mode 100644
index 0000000..80c4cb5
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__en____value__types.snap"
@@ -0,0 +1,136 @@
+{
+ "null": null,
+ "string": "hello",
+ "true": true,
+ "false": false,
+ "int": 123,
+ "float": 123.123,
+ "string_null": "null",
+ "string_true": "true",
+ "string_false": "false",
+ "string_int": "123",
+ "string_float": "123.123",
+ "string_empty": "",
+ "array_filled_null": [
+ null
+ ],
+ "array_filled_string": [
+ "hello"
+ ],
+ "array_filled_true": [
+ true
+ ],
+ "array_filled_false": [
+ false
+ ],
+ "array_filled_int": [
+ 123
+ ],
+ "array_filled_float": [
+ 123.123
+ ],
+ "array_empty": [],
+ "multi_array_filled_null": {
+ "hey": null
+ },
+ "multi_array_filled_string": {
+ "hey": "hello"
+ },
+ "multi_array_filled_true": {
+ "hey": true
+ },
+ "multi_array_filled_false": {
+ "hey": false
+ },
+ "multi_array_filled_int": {
+ "hey": 123
+ },
+ "multi_array_filled_float": {
+ "hey": 123.123
+ },
+ "Collection_filled_null": [
+ null
+ ],
+ "Collection_filled_string": [
+ "hello"
+ ],
+ "Collection_filled_true": [
+ true
+ ],
+ "Collection_filled_false": [
+ false
+ ],
+ "Collection_filled_int": [
+ 123
+ ],
+ "Collection_filled_float": [
+ 123.123
+ ],
+ "Collection_empty": [],
+ "multi_Collection_filled_null": {
+ "hey": null
+ },
+ "multi_Collection_filled_string": {
+ "hey": "hello"
+ },
+ "multi_Collection_filled_true": {
+ "hey": true
+ },
+ "multi_Collection_filled_false": {
+ "hey": false
+ },
+ "multi_Collection_filled_int": {
+ "hey": 123
+ },
+ "multi_Collection_filled_float": {
+ "hey": 123.123
+ },
+ "Stringable": "I'm Stringable",
+ "stdClass": {
+ "null": null,
+ "string": "hello",
+ "true": true,
+ "false": false,
+ "int": 123,
+ "float": 123.123,
+ "array_empty": [],
+ "array_filled": {
+ "attributes": {
+ "address": {
+ "line_1": 123,
+ "line_2": null,
+ "street": "abc"
+ }
+ }
+ }
+ },
+ "Closure_null": {},
+ "Closure_string": {},
+ "Closure_true": {},
+ "Closure_false": {},
+ "Closure_int": {},
+ "Closure_float": {},
+ "Closure_string_null": {},
+ "Closure_string_true": {},
+ "Closure_string_false": {},
+ "Closure_string_int": {},
+ "Closure_string_float": {},
+ "Closure_string_empty": {},
+ "Closure_Collection_filled_null": {},
+ "Closure_Collection_filled_string": {},
+ "Closure_Collection_filled_true": {},
+ "Closure_Collection_filled_false": {},
+ "Closure_Collection_filled_int": {},
+ "Closure_Collection_filled_float": {},
+ "Closure_Collection_empty": {},
+ "Closure_multi_Collection_filled_null": {},
+ "Closure_multi_Collection_filled_string": {},
+ "Closure_multi_Collection_filled_true": {},
+ "Closure_multi_Collection_filled_false": {},
+ "Closure_multi_Collection_filled_int": {},
+ "Closure_multi_Collection_filled_float": {},
+ "Closure_Stringable": {},
+ "Closure_stdClass": {},
+ "Closure_Closure_stdClass": {},
+ "Closure_with_params": {}
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr_____JSON_file_.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr_____JSON_file_.snap"
new file mode 100644
index 0000000..f8239d3
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr_____JSON_file_.snap"
@@ -0,0 +1,18 @@
+{
+ "Hello": "[FR][JSON] Hello!",
+ "pages.dashboard.nav.users.billing": "[FR][JSON] overriden 'pages.dashboard.nav.users.billing'!",
+ "pages": {
+ "dashboard": {
+ "nav": {
+ "users": {
+ "logout": "[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!"
+ }
+ }
+ }
+ },
+ "pages\/dashboard\/nav": {
+ "users": {
+ "logout": "[FR][JSON][bis] overriden 'pages.dashboard.nav.users.logout'!"
+ }
+ }
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____auth.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____auth.snap"
new file mode 100644
index 0000000..a948491
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____auth.snap"
@@ -0,0 +1,4 @@
+{
+ "failed_bis": "[FR] These credentials do not match our records.",
+ "password": "[FR][Modified] - The provided password is incorrect."
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____pages_dashboard_nav.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____pages_dashboard_nav.snap"
new file mode 100644
index 0000000..ba86db6
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____pages_dashboard_nav.snap"
@@ -0,0 +1,8 @@
+{
+ "users": {
+ "profile": "[FR] Profile",
+ "billing": "[FR] Billing",
+ "password": "[FR] Password",
+ "logout": "[FR] Log out"
+ }
+}
\ No newline at end of file
diff --git "a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____some__package__pages_dashboard_nav.snap" "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____some__package__pages_dashboard_nav.snap"
new file mode 100644
index 0000000..cff55c7
--- /dev/null
+++ "b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest/_it_works__\342\206\222__fr____some__package__pages_dashboard_nav.snap"
@@ -0,0 +1,6 @@
+{
+ "users": {
+ "password": "[Vendor][FR] - Password",
+ "password_bis": "[Vendor][FR] - Password_bis"
+ }
+}
\ No newline at end of file
diff --git a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesActionTest/it_works.snap b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesActionTest/it_works.snap
new file mode 100644
index 0000000..9f66898
--- /dev/null
+++ b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesActionTest/it_works.snap
@@ -0,0 +1,1107 @@
+[
+ {
+ "locale": "en",
+ "group": "auth",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "failed",
+ "value": "These credentials do not match our records.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "password",
+ "value": "[Modified] - The provided password is incorrect.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "throttle",
+ "value": "Too many login attempts. Please try again in :seconds seconds.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "failed_bis",
+ "value": "These credentials do not match our records.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "fr",
+ "group": "auth",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "failed_bis",
+ "value": "[FR] These credentials do not match our records.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "password",
+ "value": "[FR][Modified] - The provided password is incorrect.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": "email",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "Contact",
+ "value": "contact@example.com",
+ "meta": {
+ "original_value_type": "object"
+ }
+ },
+ {
+ "key": "Help",
+ "value": "help@example.com",
+ "meta": {
+ "original_value_type": "object"
+ }
+ },
+ {
+ "key": "Sales",
+ "value": "sales@example.com",
+ "meta": {
+ "original_value_type": "object"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": "flash",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "user.creation.success",
+ "value": "Some success message.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "user.creation.error",
+ "value": "Some error message.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "organization",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "key": "billing",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": "pages\/dashboard\/nav",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "users.profile",
+ "value": "Profile",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.billing",
+ "value": "Billing",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.password",
+ "value": "Password",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.logout",
+ "value": "Log out",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "fr",
+ "group": "pages\/dashboard\/nav",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "users.profile",
+ "value": "[FR] Profile",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.billing",
+ "value": "[FR] Billing",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.password",
+ "value": "[FR] Password",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.logout",
+ "value": "[FR] Log out",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": "value_types",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "null",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "string",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "true",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "false",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "int",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "float",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "string_null",
+ "value": "null",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_true",
+ "value": "true",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_false",
+ "value": "false",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_int",
+ "value": "123",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_float",
+ "value": "123.123",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_empty",
+ "value": "",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "array_filled_null.0",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "array_filled_string.0",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "array_filled_true.0",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "array_filled_false.0",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "array_filled_int.0",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "array_filled_float.0",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "array_empty",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "key": "multi_array_filled_null.hey",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "multi_array_filled_string.hey",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "multi_array_filled_true.hey",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "multi_array_filled_false.hey",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "multi_array_filled_int.hey",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "multi_array_filled_float.hey",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Collection_filled_null.0",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Collection_filled_string.0",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Collection_filled_true.0",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Collection_filled_false.0",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Collection_filled_int.0",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Collection_filled_float.0",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Collection_empty",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "key": "multi_Collection_filled_null.hey",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "multi_Collection_filled_string.hey",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "multi_Collection_filled_true.hey",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "multi_Collection_filled_false.hey",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "multi_Collection_filled_int.hey",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "multi_Collection_filled_float.hey",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Stringable",
+ "value": "I'm Stringable",
+ "meta": {
+ "original_value_type": "object"
+ }
+ },
+ {
+ "key": "stdClass.null",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "stdClass.string",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "stdClass.true",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "stdClass.false",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "stdClass.int",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "stdClass.float",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "stdClass.array_empty",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "key": "stdClass.array_filled.attributes.address.line_1",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "stdClass.array_filled.attributes.address.line_2",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "stdClass.array_filled.attributes.address.street",
+ "value": "abc",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_null",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Closure_string",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_true",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_false",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_int",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Closure_float",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Closure_string_null",
+ "value": "null",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_string_true",
+ "value": "true",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_string_false",
+ "value": "false",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_string_int",
+ "value": "123",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_string_float",
+ "value": "123.123",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_string_empty",
+ "value": "",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_Collection_filled_null.0",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Closure_Collection_filled_string.0",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_Collection_filled_true.0",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_Collection_filled_false.0",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_Collection_filled_int.0",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Closure_Collection_filled_float.0",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Closure_Collection_empty",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "key": "Closure_multi_Collection_filled_null.hey",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Closure_multi_Collection_filled_string.hey",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_multi_Collection_filled_true.hey",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_multi_Collection_filled_false.hey",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_multi_Collection_filled_int.hey",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Closure_multi_Collection_filled_float.hey",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Closure_Stringable",
+ "value": "I'm Stringable",
+ "meta": {
+ "original_value_type": "object"
+ }
+ },
+ {
+ "key": "Closure_stdClass.null",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Closure_stdClass.string",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_stdClass.true",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_stdClass.false",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_stdClass.int",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Closure_stdClass.float",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Closure_stdClass.array_empty",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "key": "Closure_stdClass.array_filled.attributes.address.line_1",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Closure_stdClass.array_filled.attributes.address.line_2",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Closure_stdClass.array_filled.attributes.address.street",
+ "value": "abc",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.null",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.string",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.true",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.false",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.int",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.float",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.array_empty",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.array_filled.attributes.address.line_1",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.array_filled.attributes.address.line_2",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "Closure_Closure_stdClass.array_filled.attributes.address.street",
+ "value": "abc",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "Closure_with_params",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": null,
+ "namespace": null,
+ "lines": [
+ {
+ "key": "Hello",
+ "value": "[JSON] Hello!",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "pages.dashboard.nav.users.billing",
+ "value": "[JSON] overriden 'pages.dashboard.nav.users.billing'!",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "pages.dashboard.nav.users.logout",
+ "value": "[JSON] overriden 'pages.dashboard.nav.users.logout'!",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "null",
+ "value": null,
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "key": "string",
+ "value": "hello",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "true",
+ "value": true,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "false",
+ "value": false,
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "key": "int",
+ "value": 123,
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "key": "float",
+ "value": 123.123,
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "key": "string_null",
+ "value": "null",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_true",
+ "value": "true",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_false",
+ "value": "false",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_int",
+ "value": "123",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_float",
+ "value": "123.123",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "string_empty",
+ "value": "",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "array_empty",
+ "value": null,
+ "meta": {
+ "original_value_type": "array"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "fr",
+ "group": null,
+ "namespace": null,
+ "lines": [
+ {
+ "key": "Hello",
+ "value": "[FR][JSON] Hello!",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "pages.dashboard.nav.users.billing",
+ "value": "[FR][JSON] overriden 'pages.dashboard.nav.users.billing'!",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "pages.dashboard.nav.users.logout",
+ "value": "[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "pages\/dashboard\/nav.users.logout",
+ "value": "[FR][JSON][bis] overriden 'pages.dashboard.nav.users.logout'!",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": "auth",
+ "namespace": "some_package",
+ "lines": [
+ {
+ "key": "failed_bis_from_vendor",
+ "value": "These credentials do not match our records.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": "example",
+ "namespace": "some_package",
+ "lines": [
+ {
+ "key": "attributes.attribute_1",
+ "value": "__str__value_1",
+ "meta": {
+ "original_value_type": "object"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "en",
+ "group": "pages\/dashboard\/nav",
+ "namespace": "some_package",
+ "lines": [
+ {
+ "key": "users.password",
+ "value": "[Vendor] - Password",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.password_bis",
+ "value": "[Vendor] - Password_bis",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ },
+ {
+ "locale": "fr",
+ "group": "pages\/dashboard\/nav",
+ "namespace": "some_package",
+ "lines": [
+ {
+ "key": "users.password",
+ "value": "[Vendor][FR] - Password",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "users.password_bis",
+ "value": "[Vendor][FR] - Password_bis",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": null
+ }
+]
\ No newline at end of file
diff --git a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesActionTest/it_works.snap b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesActionTest/it_works.snap
new file mode 100644
index 0000000..9908901
--- /dev/null
+++ b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesActionTest/it_works.snap
@@ -0,0 +1,67 @@
+[
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/en.json",
+ "contents": "{\n \"Hello\": \"[JSON] Hello!\",\n \"pages.dashboard.nav.users.billing\": \"[JSON] overriden 'pages.dashboard.nav.users.billing'!\",\n \"pages.dashboard.nav.users.logout\": \"[JSON] overriden 'pages.dashboard.nav.users.logout'!\",\n \"null\": null,\n \"string\": \"hello\",\n \"true\": true,\n \"false\": false,\n \"int\": 123,\n \"float\": 123.123,\n \"string_null\": \"null\",\n \"string_true\": \"true\",\n \"string_false\": \"false\",\n \"string_int\": \"123\",\n \"string_float\": \"123.123\",\n \"string_empty\": \"\",\n \"array_empty\": []\n}",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/en\/auth.php",
+ "contents": " 'These credentials do not match our records.',\n 'password' => '[Modified] - The provided password is incorrect.',\n 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',\n 'failed_bis' => 'These credentials do not match our records.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/en\/email.php",
+ "contents": " 'contact@example.com',\n 'Help' => 'help@example.com',\n 'Sales' => 'sales@example.com',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/en\/flash.php",
+ "contents": " [\n 'creation' => [\n 'success' => 'Some success message.',\n 'error' => 'Some error message.',\n ],\n ],\n 'organization' => [],\n 'billing' => null,\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/en\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'profile' => 'Profile',\n 'billing' => 'Billing',\n 'password' => 'Password',\n 'logout' => 'Log out',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/en\/value_types.php",
+ "contents": " null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'string_null' => 'null',\n 'string_true' => 'true',\n 'string_false' => 'false',\n 'string_int' => '123',\n 'string_float' => '123.123',\n 'string_empty' => '',\n 'array_filled_null' => [\n null,\n ],\n 'array_filled_string' => [\n 'hello',\n ],\n 'array_filled_true' => [\n true,\n ],\n 'array_filled_false' => [\n false,\n ],\n 'array_filled_int' => [\n 123,\n ],\n 'array_filled_float' => [\n 123.123,\n ],\n 'array_empty' => [],\n 'multi_array_filled_null' => [\n 'hey' => null,\n ],\n 'multi_array_filled_string' => [\n 'hey' => 'hello',\n ],\n 'multi_array_filled_true' => [\n 'hey' => true,\n ],\n 'multi_array_filled_false' => [\n 'hey' => false,\n ],\n 'multi_array_filled_int' => [\n 'hey' => 123,\n ],\n 'multi_array_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Collection_filled_null' => [\n null,\n ],\n 'Collection_filled_string' => [\n 'hello',\n ],\n 'Collection_filled_true' => [\n true,\n ],\n 'Collection_filled_false' => [\n false,\n ],\n 'Collection_filled_int' => [\n 123,\n ],\n 'Collection_filled_float' => [\n 123.123,\n ],\n 'Collection_empty' => [],\n 'multi_Collection_filled_null' => [\n 'hey' => null,\n ],\n 'multi_Collection_filled_string' => [\n 'hey' => 'hello',\n ],\n 'multi_Collection_filled_true' => [\n 'hey' => true,\n ],\n 'multi_Collection_filled_false' => [\n 'hey' => false,\n ],\n 'multi_Collection_filled_int' => [\n 'hey' => 123,\n ],\n 'multi_Collection_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Stringable' => 'I\\'m Stringable',\n 'stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_null' => null,\n 'Closure_string' => 'hello',\n 'Closure_true' => true,\n 'Closure_false' => false,\n 'Closure_int' => 123,\n 'Closure_float' => 123.123,\n 'Closure_string_null' => 'null',\n 'Closure_string_true' => 'true',\n 'Closure_string_false' => 'false',\n 'Closure_string_int' => '123',\n 'Closure_string_float' => '123.123',\n 'Closure_string_empty' => '',\n 'Closure_Collection_filled_null' => [\n null,\n ],\n 'Closure_Collection_filled_string' => [\n 'hello',\n ],\n 'Closure_Collection_filled_true' => [\n true,\n ],\n 'Closure_Collection_filled_false' => [\n false,\n ],\n 'Closure_Collection_filled_int' => [\n 123,\n ],\n 'Closure_Collection_filled_float' => [\n 123.123,\n ],\n 'Closure_Collection_empty' => [],\n 'Closure_multi_Collection_filled_null' => [\n 'hey' => null,\n ],\n 'Closure_multi_Collection_filled_string' => [\n 'hey' => 'hello',\n ],\n 'Closure_multi_Collection_filled_true' => [\n 'hey' => true,\n ],\n 'Closure_multi_Collection_filled_false' => [\n 'hey' => false,\n ],\n 'Closure_multi_Collection_filled_int' => [\n 'hey' => 123,\n ],\n 'Closure_multi_Collection_filled_float' => [\n 'hey' => 123.123,\n ],\n 'Closure_Stringable' => 'I\\'m Stringable',\n 'Closure_stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_Closure_stdClass' => [\n 'null' => null,\n 'string' => 'hello',\n 'true' => true,\n 'false' => false,\n 'int' => 123,\n 'float' => 123.123,\n 'array_empty' => [],\n 'array_filled' => [\n 'attributes' => [\n 'address' => [\n 'line_1' => 123,\n 'line_2' => null,\n 'street' => 'abc',\n ],\n ],\n ],\n ],\n 'Closure_with_params' => null,\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/fr.json",
+ "contents": "{\n \"Hello\": \"[FR][JSON] Hello!\",\n \"pages.dashboard.nav.users.billing\": \"[FR][JSON] overriden 'pages.dashboard.nav.users.billing'!\",\n \"pages.dashboard.nav.users.logout\": \"[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!\",\n \"pages\/dashboard\/nav.users.logout\": \"[FR][JSON][bis] overriden 'pages.dashboard.nav.users.logout'!\"\n}",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/fr\/auth.php",
+ "contents": " '[FR] These credentials do not match our records.',\n 'password' => '[FR][Modified] - The provided password is incorrect.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/fr\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'profile' => '[FR] Profile',\n 'billing' => '[FR] Billing',\n 'password' => '[FR] Password',\n 'logout' => '[FR] Log out',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/vendor\/some_package\/en\/auth.php",
+ "contents": " 'These credentials do not match our records.',\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/vendor\/some_package\/en\/example.php",
+ "contents": " [\n 'attribute_1' => '__str__value_1',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/vendor\/some_package\/en\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'password' => '[Vendor] - Password',\n 'password_bis' => '[Vendor] - Password_bis',\n ],\n];\n",
+ "lock": false
+ },
+ {
+ "path": "\/transl-me\/laravel-transl\/TestSupport\/lang\/vendor\/some_package\/fr\/pages\/dashboard\/nav.php",
+ "contents": " [\n 'password' => '[Vendor][FR] - Password',\n 'password_bis' => '[Vendor][FR] - Password_bis',\n ],\n];\n",
+ "lock": false
+ }
+]
\ No newline at end of file
diff --git a/tests/.pest/snapshots/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetTest/it_works.snap b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetTest/it_works.snap
new file mode 100644
index 0000000..bead92b
--- /dev/null
+++ b/tests/.pest/snapshots/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetTest/it_works.snap
@@ -0,0 +1,38 @@
+{
+ "locale": "en",
+ "group": "auth",
+ "namespace": null,
+ "lines": [
+ {
+ "key": "failed",
+ "value": "These credentials do not match our records.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "password",
+ "value": "[Modified] - The provided password is incorrect.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "throttle",
+ "value": "Too many login attempts. Please try again in :seconds seconds.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "key": "failed_bis",
+ "value": "These credentials do not match our records.",
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "meta": {
+ "some_metadata": "some_value"
+ }
+}
\ No newline at end of file
diff --git a/tests/.pest/snapshots/src/Support/Analysis/ProjectAnalysisTest/it_works.snap b/tests/.pest/snapshots/src/Support/Analysis/ProjectAnalysisTest/it_works.snap
new file mode 100644
index 0000000..746c90d
--- /dev/null
+++ b/tests/.pest/snapshots/src/Support/Analysis/ProjectAnalysisTest/it_works.snap
@@ -0,0 +1,722 @@
+{
+ "summary": {
+ "unique_translation_key_count": 131,
+ "unique_translation_set_count": 9,
+ "translation_key_count": 143,
+ "translation_set_count": 13,
+ "translation_line_count": 143,
+ "translation_line_word_count": 193,
+ "translation_line_character_count": 1327
+ },
+ "locales": {
+ "en": {
+ "summary": {
+ "translation_set_count": 9,
+ "translation_line_count": 131,
+ "translation_line_word_count": 152,
+ "translation_line_character_count": 937
+ },
+ "translation_sets": {
+ "UNGROUPED": {
+ "summary": {
+ "translation_line_count": 16,
+ "translation_line_word_count": 18,
+ "translation_line_character_count": 165
+ },
+ "lines": {
+ "Hello": {
+ "word_count": 2,
+ "character_count": 13
+ },
+ "array_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "null": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "pages.dashboard.nav.users.billing": {
+ "word_count": 3,
+ "character_count": 53
+ },
+ "pages.dashboard.nav.users.logout": {
+ "word_count": 3,
+ "character_count": 52
+ },
+ "string": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "string_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "string_false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "string_float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "string_int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "string_null": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "string_true": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "true": {
+ "word_count": 1,
+ "character_count": 4
+ }
+ }
+ },
+ "auth": {
+ "summary": {
+ "translation_line_count": 4,
+ "translation_line_word_count": 31,
+ "translation_line_character_count": 196
+ },
+ "lines": {
+ "failed": {
+ "word_count": 7,
+ "character_count": 43
+ },
+ "failed_bis": {
+ "word_count": 7,
+ "character_count": 43
+ },
+ "password": {
+ "word_count": 7,
+ "character_count": 48
+ },
+ "throttle": {
+ "word_count": 10,
+ "character_count": 62
+ }
+ }
+ },
+ "email": {
+ "summary": {
+ "translation_line_count": 3,
+ "translation_line_word_count": 3,
+ "translation_line_character_count": 52
+ },
+ "lines": {
+ "Contact": {
+ "word_count": 1,
+ "character_count": 19
+ },
+ "Help": {
+ "word_count": 1,
+ "character_count": 16
+ },
+ "Sales": {
+ "word_count": 1,
+ "character_count": 17
+ }
+ }
+ },
+ "flash": {
+ "summary": {
+ "translation_line_count": 4,
+ "translation_line_word_count": 6,
+ "translation_line_character_count": 40
+ },
+ "lines": {
+ "billing": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "organization": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "user.creation.error": {
+ "word_count": 3,
+ "character_count": 19
+ },
+ "user.creation.success": {
+ "word_count": 3,
+ "character_count": 21
+ }
+ }
+ },
+ "pages\/dashboard\/nav": {
+ "summary": {
+ "translation_line_count": 4,
+ "translation_line_word_count": 5,
+ "translation_line_character_count": 29
+ },
+ "lines": {
+ "users.billing": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "users.logout": {
+ "word_count": 2,
+ "character_count": 7
+ },
+ "users.password": {
+ "word_count": 1,
+ "character_count": 8
+ },
+ "users.profile": {
+ "word_count": 1,
+ "character_count": 7
+ }
+ }
+ },
+ "some_package::auth": {
+ "summary": {
+ "translation_line_count": 1,
+ "translation_line_word_count": 7,
+ "translation_line_character_count": 43
+ },
+ "lines": {
+ "failed_bis_from_vendor": {
+ "word_count": 7,
+ "character_count": 43
+ }
+ }
+ },
+ "some_package::example": {
+ "summary": {
+ "translation_line_count": 1,
+ "translation_line_word_count": 1,
+ "translation_line_character_count": 14
+ },
+ "lines": {
+ "attributes.attribute_1": {
+ "word_count": 1,
+ "character_count": 14
+ }
+ }
+ },
+ "some_package::pages\/dashboard\/nav": {
+ "summary": {
+ "translation_line_count": 2,
+ "translation_line_word_count": 6,
+ "translation_line_character_count": 42
+ },
+ "lines": {
+ "users.password": {
+ "word_count": 3,
+ "character_count": 19
+ },
+ "users.password_bis": {
+ "word_count": 3,
+ "character_count": 23
+ }
+ }
+ },
+ "value_types": {
+ "summary": {
+ "translation_line_count": 96,
+ "translation_line_word_count": 75,
+ "translation_line_character_count": 356
+ },
+ "lines": {
+ "Closure_Closure_stdClass.array_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_Closure_stdClass.array_filled.attributes.address.line_1": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_Closure_stdClass.array_filled.attributes.address.line_2": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_Closure_stdClass.array_filled.attributes.address.street": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_Closure_stdClass.false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_Closure_stdClass.float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "Closure_Closure_stdClass.int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_Closure_stdClass.null": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_Closure_stdClass.string": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_Closure_stdClass.true": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Closure_Collection_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_Collection_filled_false.0": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_Collection_filled_float.0": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "Closure_Collection_filled_int.0": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_Collection_filled_null.0": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_Collection_filled_string.0": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_Collection_filled_true.0": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Closure_Stringable": {
+ "word_count": 2,
+ "character_count": 14
+ },
+ "Closure_false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "Closure_int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_multi_Collection_filled_false.hey": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_multi_Collection_filled_float.hey": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "Closure_multi_Collection_filled_int.hey": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_multi_Collection_filled_null.hey": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_multi_Collection_filled_string.hey": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_multi_Collection_filled_true.hey": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Closure_null": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_stdClass.array_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_stdClass.array_filled.attributes.address.line_1": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_stdClass.array_filled.attributes.address.line_2": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_stdClass.array_filled.attributes.address.street": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_stdClass.false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_stdClass.float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "Closure_stdClass.int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_stdClass.null": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_stdClass.string": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_stdClass.true": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Closure_string": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_string_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Closure_string_false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Closure_string_float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "Closure_string_int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Closure_string_null": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Closure_string_true": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Closure_true": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Closure_with_params": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Collection_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Collection_filled_false.0": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Collection_filled_float.0": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "Collection_filled_int.0": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "Collection_filled_null.0": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "Collection_filled_string.0": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "Collection_filled_true.0": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "Stringable": {
+ "word_count": 2,
+ "character_count": 14
+ },
+ "array_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "array_filled_false.0": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "array_filled_float.0": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "array_filled_int.0": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "array_filled_null.0": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "array_filled_string.0": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "array_filled_true.0": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "multi_Collection_filled_false.hey": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "multi_Collection_filled_float.hey": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "multi_Collection_filled_int.hey": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "multi_Collection_filled_null.hey": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "multi_Collection_filled_string.hey": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "multi_Collection_filled_true.hey": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "multi_array_filled_false.hey": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "multi_array_filled_float.hey": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "multi_array_filled_int.hey": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "multi_array_filled_null.hey": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "multi_array_filled_string.hey": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "multi_array_filled_true.hey": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "null": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "stdClass.array_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "stdClass.array_filled.attributes.address.line_1": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "stdClass.array_filled.attributes.address.line_2": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "stdClass.array_filled.attributes.address.street": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "stdClass.false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "stdClass.float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "stdClass.int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "stdClass.null": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "stdClass.string": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "stdClass.true": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "string": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "string_empty": {
+ "word_count": 0,
+ "character_count": 0
+ },
+ "string_false": {
+ "word_count": 1,
+ "character_count": 5
+ },
+ "string_float": {
+ "word_count": 1,
+ "character_count": 7
+ },
+ "string_int": {
+ "word_count": 1,
+ "character_count": 3
+ },
+ "string_null": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "string_true": {
+ "word_count": 1,
+ "character_count": 4
+ },
+ "true": {
+ "word_count": 1,
+ "character_count": 4
+ }
+ }
+ }
+ }
+ },
+ "fr": {
+ "summary": {
+ "translation_set_count": 4,
+ "translation_line_count": 12,
+ "translation_line_word_count": 41,
+ "translation_line_character_count": 390
+ },
+ "translation_sets": {
+ "UNGROUPED": {
+ "summary": {
+ "translation_line_count": 4,
+ "translation_line_word_count": 11,
+ "translation_line_character_count": 191
+ },
+ "lines": {
+ "Hello": {
+ "word_count": 2,
+ "character_count": 17
+ },
+ "pages.dashboard.nav.users.billing": {
+ "word_count": 3,
+ "character_count": 57
+ },
+ "pages.dashboard.nav.users.logout": {
+ "word_count": 3,
+ "character_count": 56
+ },
+ "pages\/dashboard\/nav.users.logout": {
+ "word_count": 3,
+ "character_count": 61
+ }
+ }
+ },
+ "auth": {
+ "summary": {
+ "translation_line_count": 2,
+ "translation_line_word_count": 15,
+ "translation_line_character_count": 100
+ },
+ "lines": {
+ "failed_bis": {
+ "word_count": 8,
+ "character_count": 48
+ },
+ "password": {
+ "word_count": 7,
+ "character_count": 52
+ }
+ }
+ },
+ "pages\/dashboard\/nav": {
+ "summary": {
+ "translation_line_count": 4,
+ "translation_line_word_count": 9,
+ "translation_line_character_count": 49
+ },
+ "lines": {
+ "users.billing": {
+ "word_count": 2,
+ "character_count": 12
+ },
+ "users.logout": {
+ "word_count": 3,
+ "character_count": 12
+ },
+ "users.password": {
+ "word_count": 2,
+ "character_count": 13
+ },
+ "users.profile": {
+ "word_count": 2,
+ "character_count": 12
+ }
+ }
+ },
+ "some_package::pages\/dashboard\/nav": {
+ "summary": {
+ "translation_line_count": 2,
+ "translation_line_word_count": 6,
+ "translation_line_character_count": 50
+ },
+ "lines": {
+ "users.password": {
+ "word_count": 3,
+ "character_count": 23
+ },
+ "users.password_bis": {
+ "word_count": 3,
+ "character_count": 27
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..4dc15a0
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,36 @@
+in(__DIR__);
+
+expect()->extend('toMatchSnapshotStandardizedUsing', function (Closure $callback, string $message = ''): Expectation {
+ return expect($callback($this))->toMatchSnapshot($message);
+});
+
+expect()->extend('toMatchStandardizedSnapshot', function (string $message = ''): Expectation {
+ $callback = static function (Expectation $expectation): mixed {
+ return SnapshotExpectationHelper::new($expectation)->handle();
+ };
+
+ return expect($this->value)->toMatchSnapshotStandardizedUsing($callback, $message);
+});
+
+// expect()->extend('toMatchConsoleOutput', function (string $output = ''): Expectation {
+// $callback = function (Expectation $expectation): string {
+// return str($expectation->value)
+// ->replace(["\r\n", "\r"], "\n")
+// ->replace(['░', '▓', '.'], '')
+// ->replaceMatches('/[0-9]+\s\[/', '')
+// ->replace([']', " \n"], '')
+// ->replace("\n\n", "\n")
+// ->replace(' ', '')
+// ->value();
+// };
+
+// return expect($this->value)->toMatchSnapshotStandardizedUsing($callback, $output);
+// });
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..d0df1ea
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,119 @@
+setBasePath($this->getTestSupportDirectory());
+
+ config()->set('transl', [
+ 'reporting' => [
+ 'should_report_missing_translation_keys' => true,
+ 'report_missing_translation_keys_using' => ReportMissingTranslationKeysAction::class,
+ 'silently_discard_exceptions' => false,
+ ],
+ 'defaults' => [
+ 'project' => 'example_auth_key',
+ 'project_options' => [
+ 'transl_directory' => storage_path('app/.transl'),
+ 'locale' => [
+ 'default' => config('app.locale'),
+ 'fallback' => config('app.fallback'),
+ 'throw_on_disallowed_locale' => true,
+ ],
+ 'branching' => [
+ 'default_branch_name' => 'default',
+ 'mirror_current_branch' => true,
+ 'conflict_resolution' => BranchingConflictResolutionEnum::MERGE_BUT_THROW->value,
+ ],
+ ],
+ ],
+ 'projects' => [
+ [
+ 'auth_key' => 'example_auth_key',
+ 'name' => 'example_name',
+ 'options' => [],
+ 'drivers' => [
+ LocalFilesDriver::class,
+ ],
+ ],
+ ],
+ ]);
+
+ Http::preventStrayRequests();
+
+ Http::fake([
+ 'https://api.transl.me/v0/reports/missing-translation-keys' => Http::response(),
+ ]);
+ }
+
+ /**
+ * Get package providers.
+ *
+ * @param \Illuminate\Foundation\Application $app
+ * @return array
+ */
+ protected function getPackageProviders($app): array
+ {
+ return [
+ TranslServiceProvider::class,
+ AppServiceProvider::class,
+ ];
+ }
+
+ protected function helpers(): Helpers
+ {
+ return Helpers::new();
+ }
+
+ protected function getLangDirectory(string $path = ''): string
+ {
+ return $this->getTestSupportDirectory("/lang/{$path}");
+ }
+
+ protected function getFixtureDirectory(string $path = ''): string
+ {
+ return $this->getTestSupportDirectory("/__fixture/{$path}");
+ }
+
+ protected function getTestSupportDirectory(string $path = ''): string
+ {
+ return $this->getTestDirectory("/TestSupport/{$path}");
+ }
+
+ protected function getTestDirectory(string $path = ''): string
+ {
+ return $this->getPackageDirectory("/tests/{$path}");
+ }
+
+ protected function getPackageDirectory(string $path = ''): string
+ {
+ return rtrim(str_replace(['\\', '//'], '/', dirname(__DIR__) . '/' . $path), '/');
+ }
+}
diff --git a/tests/TestSupport/__fixture/pull_stub_responses.json b/tests/TestSupport/__fixture/pull_stub_responses.json
new file mode 100644
index 0000000..d69b7f8
--- /dev/null
+++ b/tests/TestSupport/__fixture/pull_stub_responses.json
@@ -0,0 +1,1096 @@
+{
+ "https://api.transl.me/v0/commands/yolo/pull": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "set_1",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_1",
+ "locale": "en",
+ "group": "auth",
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_1",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_1",
+ "key": "failed",
+ "value": "These credentials do not match our records."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_2",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_2",
+ "key": "password",
+ "value": "[Modified] - The provided password is incorrect."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_3",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_3",
+ "key": "throttle",
+ "value": "Too many login attempts. Please try again in :seconds seconds."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_4",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_4",
+ "key": "failed_bis",
+ "value": "These credentials do not match our records."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_2",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_2",
+ "locale": "en",
+ "group": "email",
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_5",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_5",
+ "key": "Contact",
+ "value": "contact@example.com"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "object"
+ }
+ },
+ {
+ "id": "line_6",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_6",
+ "key": "Help",
+ "value": "help@example.com"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "object"
+ }
+ },
+ {
+ "id": "line_7",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_7",
+ "key": "Sales",
+ "value": "sales@example.com"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "object"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_3",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_3",
+ "locale": "en",
+ "group": "flash",
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_8",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_8",
+ "key": "user.creation.success",
+ "value": "Some success message."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_9",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_9",
+ "key": "user.creation.error",
+ "value": "Some error message."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_10",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_10",
+ "key": "organization",
+ "value": []
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "id": "line_11",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_11",
+ "key": "billing",
+ "value": null
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_4",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_4",
+ "locale": "en",
+ "group": "pages/dashboard/nav",
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_12",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_12",
+ "key": "users.profile",
+ "value": "Profile"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_13",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_13",
+ "key": "users.billing",
+ "value": "Billing"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_14",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_14",
+ "key": "users.password",
+ "value": "Password"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_15",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_15",
+ "key": "users.logout",
+ "value": "Log out"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_5",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_5",
+ "locale": "en",
+ "group": "value_types",
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_16",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_16",
+ "key": "null",
+ "value": null
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "id": "line_17",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_17",
+ "key": "string",
+ "value": "hello"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_18",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_18",
+ "key": "true",
+ "value": true
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "id": "line_19",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_19",
+ "key": "false",
+ "value": false
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "id": "line_20",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_20",
+ "key": "int",
+ "value": 123
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "id": "line_21",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_21",
+ "key": "float",
+ "value": 123.123
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "id": "line_22",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_22",
+ "key": "string_null",
+ "value": "null"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_23",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_23",
+ "key": "string_true",
+ "value": "true"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_24",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_24",
+ "key": "string_false",
+ "value": "false"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_25",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_25",
+ "key": "string_int",
+ "value": "123"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_26",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_26",
+ "key": "string_float",
+ "value": "123.123"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_27",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_27",
+ "key": "string_empty",
+ "value": ""
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_28",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_28",
+ "key": "array_empty",
+ "value": []
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "array"
+ }
+ },
+ {
+ "id": "line_29",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_29",
+ "key": "Stringable",
+ "value": "I'm Stringable"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "object"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ }
+ ],
+ "pagination": {
+ "type": "cursor",
+ "attributes": {
+ "path": "https://api.transl.me/v0/commands/yolo/pull",
+ "per_page": 5,
+ "has_more_pages": true,
+ "prev_cursor": null,
+ "prev_page_url": null,
+ "next_cursor": "eyJpZCI6NSwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ",
+ "next_page_url": "https://api.transl.me/v0/commands/yolo/pull?cursor=eyJpZCI6NSwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ"
+ }
+ },
+ "meta": null
+ },
+ "https://api.transl.me/v0/commands/yolo/pull?cursor=eyJpZCI6NSwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "set_6",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_6",
+ "locale": "en",
+ "group": null,
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_30",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_30",
+ "key": "Hello",
+ "value": "[JSON] Hello!"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_31",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_31",
+ "key": "pages.dashboard.nav.users.billing",
+ "value": "[JSON] overriden 'pages.dashboard.nav.users.billing'!"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_32",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_32",
+ "key": "pages.dashboard.nav.users.logout",
+ "value": "[JSON] overriden 'pages.dashboard.nav.users.logout'!"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_33",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_33",
+ "key": "null",
+ "value": null
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "NULL"
+ }
+ },
+ {
+ "id": "line_34",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_34",
+ "key": "string",
+ "value": "hello"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_35",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_35",
+ "key": "true",
+ "value": true
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "id": "line_36",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_36",
+ "key": "false",
+ "value": false
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "boolean"
+ }
+ },
+ {
+ "id": "line_37",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_37",
+ "key": "int",
+ "value": 123
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "integer"
+ }
+ },
+ {
+ "id": "line_38",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_38",
+ "key": "float",
+ "value": 123.123
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "double"
+ }
+ },
+ {
+ "id": "line_39",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_39",
+ "key": "string_null",
+ "value": "null"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_40",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_40",
+ "key": "string_true",
+ "value": "true"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_41",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_41",
+ "key": "string_false",
+ "value": "false"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_42",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_42",
+ "key": "string_int",
+ "value": "123"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_43",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_43",
+ "key": "string_float",
+ "value": "123.123"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_44",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_44",
+ "key": "string_empty",
+ "value": ""
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_45",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_45",
+ "key": "array_empty",
+ "value": []
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "array"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_7",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_7",
+ "locale": "fr",
+ "group": "auth",
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_46",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_46",
+ "key": "failed_bis",
+ "value": "[FR] These credentials do not match our records."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_47",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_47",
+ "key": "password",
+ "value": "[FR][Modified] - The provided password is incorrect."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_8",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_8",
+ "locale": "fr",
+ "group": "pages/dashboard/nav",
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_48",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_48",
+ "key": "users.profile",
+ "value": "[FR] Profile"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_49",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_49",
+ "key": "users.billing",
+ "value": "[FR] Billing"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_50",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_50",
+ "key": "users.password",
+ "value": "[FR] Password"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_51",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_51",
+ "key": "users.logout",
+ "value": "[FR] Log out"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_9",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_9",
+ "locale": "fr",
+ "group": null,
+ "namespace": null
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_52",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_52",
+ "key": "Hello",
+ "value": "[FR][JSON] Hello!"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_53",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_53",
+ "key": "pages.dashboard.nav.users.billing",
+ "value": "[FR][JSON] overriden 'pages.dashboard.nav.users.billing'!"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_54",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_54",
+ "key": "pages.dashboard.nav.users.logout",
+ "value": "[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_10",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_10",
+ "locale": "en",
+ "group": "auth",
+ "namespace": "some_package"
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_55",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_55",
+ "key": "failed_bis_from_vendor",
+ "value": "These credentials do not match our records."
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ }
+ ],
+ "pagination": {
+ "type": "cursor",
+ "attributes": {
+ "path": "https://api.transl.me/v0/commands/yolo/pull",
+ "per_page": 5,
+ "has_more_pages": true,
+ "prev_cursor": "eyJpZCI6NiwiX3BvaW50c1RvTmV4dEl0ZW1zIjpmYWxzZX0",
+ "prev_page_url": "https://api.transl.me/v0/commands/yolo/pull?cursor=eyJpZCI6NiwiX3BvaW50c1RvTmV4dEl0ZW1zIjpmYWxzZX0",
+ "next_cursor": "eyJpZCI6MTAsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0",
+ "next_page_url": "https://api.transl.me/v0/commands/yolo/pull?cursor=eyJpZCI6MTAsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0"
+ }
+ },
+ "meta": null
+ },
+ "https://api.transl.me/v0/commands/yolo/pull?cursor=eyJpZCI6MTAsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "set_11",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_11",
+ "locale": "en",
+ "group": "example",
+ "namespace": "some_package"
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_56",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_56",
+ "key": "attributes.attribute_1",
+ "value": "__str__value_1"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "object"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_12",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_12",
+ "locale": "en",
+ "group": "pages/dashboard/nav",
+ "namespace": "some_package"
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_57",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_57",
+ "key": "users.password",
+ "value": "[Vendor] - Password"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_58",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_58",
+ "key": "users.password_bis",
+ "value": "[Vendor] - Password_bis"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ },
+ {
+ "id": "set_13",
+ "type": "translation_set",
+ "attributes": {
+ "id": "set_13",
+ "locale": "fr",
+ "group": "pages/dashboard/nav",
+ "namespace": "some_package"
+ },
+ "relations": {
+ "lines": {
+ "type": "multiple",
+ "data": [
+ {
+ "id": "line_59",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_59",
+ "key": "users.password",
+ "value": "[Vendor][FR] - Password"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ },
+ {
+ "id": "line_60",
+ "type": "translation_line",
+ "attributes": {
+ "id": "line_60",
+ "key": "users.password_bis",
+ "value": "[Vendor][FR] - Password_bis"
+ },
+ "relations": [],
+ "meta": {
+ "original_value_type": "string"
+ }
+ }
+ ],
+ "pagination": null,
+ "meta": null
+ }
+ },
+ "meta": null
+ }
+ ],
+ "pagination": {
+ "type": "cursor",
+ "attributes": {
+ "path": "https://api.transl.me/v0/commands/yolo/pull",
+ "per_page": 5,
+ "has_more_pages": false,
+ "prev_cursor": "eyJpZCI6MTEsIl9wb2ludHNUb05leHRJdGVtcyI6ZmFsc2V9",
+ "prev_page_url": "https://api.transl.me/v0/commands/yolo/pull?cursor=eyJpZCI6MTEsIl9wb2ludHNUb05leHRJdGVtcyI6ZmFsc2V9",
+ "next_cursor": null,
+ "next_page_url": null
+ }
+ },
+ "meta": null
+ }
+}
diff --git a/tests/TestSupport/app/Helpers/Helpers.php b/tests/TestSupport/app/Helpers/Helpers.php
new file mode 100644
index 0000000..5a27a0f
--- /dev/null
+++ b/tests/TestSupport/app/Helpers/Helpers.php
@@ -0,0 +1,25 @@
+expectation->value;
+
+ if (is_array($value) && isset($value[0]) && is_array($value[0]) && isset($value[0]['path'])) {
+ return $this->handleFilesystemPutItems($value);
+ }
+
+ if (is_array($value) && isset($value[0]) && $value[0] instanceof TranslationSet) {
+ return $this->handleTranslationSets($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Catches snapshots of:
+ * - `tests/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesActionTest.php`
+ * - `tests/src/Actions/Commands/PullCommandActionTest.php`
+ * - probably others
+ */
+ protected function handleFilesystemPutItems(array $items): array
+ {
+ return collect($items)
+ ->map(function (array $item): array {
+ $item['path'] = $this->standardizePath($item['path']);
+ $item['contents'] = str_replace(["\r\n", "\r"], "\n", $item['contents']);
+
+ return $item;
+ })
+ ->sortBy(static function (array $item): string {
+ return $item['path'];
+ })
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Catches snapshots of:
+ * - `tests/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesActionTest.php`
+ * - probably others
+ */
+ protected function handleTranslationSets(array $items): array
+ {
+ return $items;
+ // return collect($items)
+ // ->map(function (TranslationSet $item): TranslationSet {
+ // $meta = $item->meta;
+
+ // if (isset($meta['language_directory'])) {
+ // $meta['language_directory'] = $this->standardizePaths($meta['language_directory']);
+ // }
+
+ // if (isset($meta['translation_file'])) {
+ // $meta['translation_file'] = $this->standardizePaths($meta['translation_file']);
+ // }
+
+ // return TranslationSet::from([
+ // ...$item->toArray(),
+ // 'meta' => $meta,
+ // ]);
+ // })
+ // ->sortBy(static function (TranslationSet $item): string {
+ // return $item->trackingKey();
+ // })
+ // ->values()
+ // ->all();
+ }
+
+ protected function standardizePath(string $path): string
+ {
+ $root = str_replace(DIRECTORY_SEPARATOR, '/', dirname(__FILE__, 4));
+
+ $path = str_replace(DIRECTORY_SEPARATOR, '/', $path);
+
+ return str_replace($root, '/transl-me/laravel-transl', $path);
+ }
+
+ protected function standardizePaths(array $paths): array
+ {
+ return collect($paths)->map(function (string $path): string {
+ return $this->standardizePath($path);
+ })->all();
+ }
+}
diff --git a/tests/TestSupport/app/Helpers/TranslationSetHelper.php b/tests/TestSupport/app/Helpers/TranslationSetHelper.php
new file mode 100644
index 0000000..3fdb9b9
--- /dev/null
+++ b/tests/TestSupport/app/Helpers/TranslationSetHelper.php
@@ -0,0 +1,77 @@
+ $set->locale,
+ 'group' => $set->group,
+ 'namespace' => $set->namespace,
+ ];
+ }
+
+ return $this->tryMakeTranslationFileFullPath(...$args);
+ }
+
+ public function determineTranslationFileRelativePath(...$args): ?string
+ {
+ if (count($args) === 1) {
+ /** @var TranslationSet $set */
+ $set = $args[0];
+
+ $args = [
+ 'locale' => $set->locale,
+ 'group' => $set->group,
+ 'namespace' => $set->namespace,
+ ];
+ }
+
+ return $this->tryMakeTranslationFileRelativePath(...$args);
+ }
+
+ protected function tryMakeTranslationFileFullPath(string $locale, ?string $group, ?string $namespace): ?string
+ {
+ $path = $this->tryMakeTranslationFileRelativePath($locale, $group, $namespace);
+
+ if (!$path) {
+ return null;
+ }
+
+ return str_replace(['\\', '/'], DIRECTORY_SEPARATOR, lang_path($path));
+ }
+
+ protected function tryMakeTranslationFileRelativePath(string $locale, ?string $group, ?string $namespace): ?string
+ {
+ $translationFileRelativePath = null;
+
+ if ($namespace && $group) {
+ $translationFileRelativePath = "vendor/{$namespace}/{$locale}/{$group}.php";
+ }
+
+ if (!$namespace && $group) {
+ $translationFileRelativePath = "{$locale}/{$group}.php";
+ }
+
+ if (!$namespace && !$group) {
+ $translationFileRelativePath = "{$locale}.json";
+ }
+
+ return $translationFileRelativePath;
+ }
+}
diff --git a/tests/TestSupport/app/Providers/AppServiceProvider.php b/tests/TestSupport/app/Providers/AppServiceProvider.php
new file mode 100644
index 0000000..120012c
--- /dev/null
+++ b/tests/TestSupport/app/Providers/AppServiceProvider.php
@@ -0,0 +1,34 @@
+loadTranslationsFrom('', 'some_package');
+ }
+}
diff --git a/tests/TestSupport/lang/en.json b/tests/TestSupport/lang/en.json
new file mode 100644
index 0000000..475e5c8
--- /dev/null
+++ b/tests/TestSupport/lang/en.json
@@ -0,0 +1,28 @@
+{
+ "Hello": "[JSON] Hello!",
+ "pages.dashboard.nav.users.billing": "[JSON] overriden 'pages.dashboard.nav.users.billing'!",
+ "pages": {
+ "dashboard": {
+ "nav": {
+ "users": {
+ "logout": "[JSON] overriden 'pages.dashboard.nav.users.logout'!"
+ }
+ }
+ }
+ },
+ "null": null,
+ "string": "hello",
+ "true": true,
+ "false": false,
+ "int": 123,
+ "float": 123.123,
+
+ "string_null": "null",
+ "string_true": "true",
+ "string_false": "false",
+ "string_int": "123",
+ "string_float": "123.123",
+ "string_empty": "",
+
+ "array_empty": []
+}
\ No newline at end of file
diff --git a/tests/TestSupport/lang/en/auth.php b/tests/TestSupport/lang/en/auth.php
new file mode 100644
index 0000000..143d36e
--- /dev/null
+++ b/tests/TestSupport/lang/en/auth.php
@@ -0,0 +1,12 @@
+ 'These credentials do not match our records.',
+ 'password' => '[Modified] - The provided password is incorrect.',
+];
diff --git a/tests/TestSupport/lang/en/email.php b/tests/TestSupport/lang/en/email.php
new file mode 100644
index 0000000..dd17862
--- /dev/null
+++ b/tests/TestSupport/lang/en/email.php
@@ -0,0 +1,24 @@
+before('@')->title()->value();
+
+ $acc[$label] = $email;
+
+ return $acc;
+}, []);
diff --git a/tests/TestSupport/lang/en/flash.php b/tests/TestSupport/lang/en/flash.php
new file mode 100644
index 0000000..6ab68c9
--- /dev/null
+++ b/tests/TestSupport/lang/en/flash.php
@@ -0,0 +1,18 @@
+ [
+ 'creation' => [
+ 'success' => 'Some success message.',
+ 'error' => 'Some error message.',
+ ],
+ ],
+ 'organization' => [],
+ 'billing' => null,
+];
diff --git a/tests/TestSupport/lang/en/pages/dashboard/nav.php b/tests/TestSupport/lang/en/pages/dashboard/nav.php
new file mode 100644
index 0000000..28ce90f
--- /dev/null
+++ b/tests/TestSupport/lang/en/pages/dashboard/nav.php
@@ -0,0 +1,15 @@
+ [
+ 'profile' => 'Profile',
+ 'billing' => 'Billing',
+ 'password' => 'Password',
+ 'logout' => 'Log out',
+ ],
+];
diff --git a/tests/TestSupport/lang/en/value_types.php b/tests/TestSupport/lang/en/value_types.php
new file mode 100644
index 0000000..f86a517
--- /dev/null
+++ b/tests/TestSupport/lang/en/value_types.php
@@ -0,0 +1,109 @@
+null = null;
+$stdClass->string = 'hello';
+$stdClass->true = true;
+$stdClass->false = false;
+$stdClass->int = 123;
+$stdClass->float = 123.123;
+$stdClass->array_empty = [];
+$stdClass->array_filled = [
+ 'attributes' => [
+ 'address' => [
+ 'line_1' => 123,
+ 'line_2' => null,
+ 'street' => 'abc',
+ ],
+ ],
+];
+
+/**
+ * This file exist to test out all kinds of
+ * translation line value types.
+ */
+return [
+ 'null' => null,
+ 'string' => 'hello',
+ 'true' => true,
+ 'false' => false,
+ 'int' => 123,
+ 'float' => 123.123,
+
+ 'string_null' => 'null',
+ 'string_true' => 'true',
+ 'string_false' => 'false',
+ 'string_int' => '123',
+ 'string_float' => '123.123',
+ 'string_empty' => '',
+
+ 'array_filled_null' => [null],
+ 'array_filled_string' => ['hello'],
+ 'array_filled_true' => [true],
+ 'array_filled_false' => [false],
+ 'array_filled_int' => [123],
+ 'array_filled_float' => [123.123],
+ 'array_empty' => [],
+
+ 'multi_array_filled_null' => ['hey' => null],
+ 'multi_array_filled_string' => ['hey' => 'hello'],
+ 'multi_array_filled_true' => ['hey' => true],
+ 'multi_array_filled_false' => ['hey' => false],
+ 'multi_array_filled_int' => ['hey' => 123],
+ 'multi_array_filled_float' => ['hey' => 123.123],
+
+ 'Collection_filled_null' => collect([null]),
+ 'Collection_filled_string' => collect(['hello']),
+ 'Collection_filled_true' => collect([true]),
+ 'Collection_filled_false' => collect([false]),
+ 'Collection_filled_int' => collect([123]),
+ 'Collection_filled_float' => collect([123.123]),
+ 'Collection_empty' => collect([]),
+
+ 'multi_Collection_filled_null' => collect(['hey' => null]),
+ 'multi_Collection_filled_string' => collect(['hey' => 'hello']),
+ 'multi_Collection_filled_true' => collect(['hey' => true]),
+ 'multi_Collection_filled_false' => collect(['hey' => false]),
+ 'multi_Collection_filled_int' => collect(['hey' => 123]),
+ 'multi_Collection_filled_float' => collect(['hey' => 123.123]),
+
+ 'Stringable' => str("i'm_stringable")->replace('_', ' ')->title(),
+ 'stdClass' => $stdClass,
+
+ 'Closure_null' => static fn () => null,
+ 'Closure_string' => static fn () => 'hello',
+ 'Closure_true' => static fn () => true,
+ 'Closure_false' => static fn () => false,
+ 'Closure_int' => static fn () => 123,
+ 'Closure_float' => static fn () => 123.123,
+
+ 'Closure_string_null' => static fn () => 'null',
+ 'Closure_string_true' => static fn () => 'true',
+ 'Closure_string_false' => static fn () => 'false',
+ 'Closure_string_int' => static fn () => '123',
+ 'Closure_string_float' => static fn () => '123.123',
+ 'Closure_string_empty' => static fn () => '',
+
+ 'Closure_Collection_filled_null' => static fn () => collect([null]),
+ 'Closure_Collection_filled_string' => static fn () => collect(['hello']),
+ 'Closure_Collection_filled_true' => static fn () => collect([true]),
+ 'Closure_Collection_filled_false' => static fn () => collect([false]),
+ 'Closure_Collection_filled_int' => static fn () => collect([123]),
+ 'Closure_Collection_filled_float' => static fn () => collect([123.123]),
+ 'Closure_Collection_empty' => static fn () => collect([]),
+
+ 'Closure_multi_Collection_filled_null' => static fn () => collect(['hey' => null]),
+ 'Closure_multi_Collection_filled_string' => static fn () => collect(['hey' => 'hello']),
+ 'Closure_multi_Collection_filled_true' => static fn () => collect(['hey' => true]),
+ 'Closure_multi_Collection_filled_false' => static fn () => collect(['hey' => false]),
+ 'Closure_multi_Collection_filled_int' => static fn () => collect(['hey' => 123]),
+ 'Closure_multi_Collection_filled_float' => static fn () => collect(['hey' => 123.123]),
+
+ 'Closure_Stringable' => static fn () => str("i'm_stringable")->replace('_', ' ')->title(),
+ 'Closure_stdClass' => static fn () => $stdClass,
+ 'Closure_Closure_stdClass' => static fn () => static fn () => $stdClass,
+ 'Closure_with_params' => static fn ($value1, $value2) => ['value1' => $value1, 'value2' => $value2],
+];
diff --git a/tests/TestSupport/lang/fr.json b/tests/TestSupport/lang/fr.json
new file mode 100644
index 0000000..c2ad187
--- /dev/null
+++ b/tests/TestSupport/lang/fr.json
@@ -0,0 +1,18 @@
+{
+ "Hello": "[FR][JSON] Hello!",
+ "pages.dashboard.nav.users.billing": "[FR][JSON] overriden 'pages.dashboard.nav.users.billing'!",
+ "pages": {
+ "dashboard": {
+ "nav": {
+ "users": {
+ "logout": "[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!"
+ }
+ }
+ }
+ },
+ "pages/dashboard/nav": {
+ "users": {
+ "logout": "[FR][JSON][bis] overriden 'pages.dashboard.nav.users.logout'!"
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/TestSupport/lang/fr/auth.php b/tests/TestSupport/lang/fr/auth.php
new file mode 100644
index 0000000..f1298d0
--- /dev/null
+++ b/tests/TestSupport/lang/fr/auth.php
@@ -0,0 +1,12 @@
+ '[FR] These credentials do not match our records.',
+ 'password' => '[FR][Modified] - The provided password is incorrect.',
+];
diff --git a/tests/TestSupport/lang/fr/pages/dashboard/nav.php b/tests/TestSupport/lang/fr/pages/dashboard/nav.php
new file mode 100644
index 0000000..d0235ff
--- /dev/null
+++ b/tests/TestSupport/lang/fr/pages/dashboard/nav.php
@@ -0,0 +1,15 @@
+ [
+ 'profile' => '[FR] Profile',
+ 'billing' => '[FR] Billing',
+ 'password' => '[FR] Password',
+ 'logout' => '[FR] Log out',
+ ],
+];
diff --git a/tests/TestSupport/lang/vendor/some_package/en/auth.php b/tests/TestSupport/lang/vendor/some_package/en/auth.php
new file mode 100644
index 0000000..a38e035
--- /dev/null
+++ b/tests/TestSupport/lang/vendor/some_package/en/auth.php
@@ -0,0 +1,14 @@
+ 'These credentials do not match our records.',
+
+];
diff --git a/tests/TestSupport/lang/vendor/some_package/en/example.php b/tests/TestSupport/lang/vendor/some_package/en/example.php
new file mode 100644
index 0000000..06cef0a
--- /dev/null
+++ b/tests/TestSupport/lang/vendor/some_package/en/example.php
@@ -0,0 +1,12 @@
+ [
+ 'attribute_1' => str('value_1')->prepend('__str__'),
+ ],
+];
diff --git a/tests/TestSupport/lang/vendor/some_package/en/pages/dashboard/nav.php b/tests/TestSupport/lang/vendor/some_package/en/pages/dashboard/nav.php
new file mode 100644
index 0000000..da4ffa9
--- /dev/null
+++ b/tests/TestSupport/lang/vendor/some_package/en/pages/dashboard/nav.php
@@ -0,0 +1,13 @@
+ [
+ 'password' => '[Vendor] - Password',
+ 'password_bis' => '[Vendor] - Password_bis',
+ ],
+];
diff --git a/tests/TestSupport/lang/vendor/some_package/fr/pages/dashboard/nav.php b/tests/TestSupport/lang/vendor/some_package/fr/pages/dashboard/nav.php
new file mode 100644
index 0000000..d159911
--- /dev/null
+++ b/tests/TestSupport/lang/vendor/some_package/fr/pages/dashboard/nav.php
@@ -0,0 +1,13 @@
+ [
+ 'password' => '[Vendor][FR] - Password',
+ 'password_bis' => '[Vendor][FR] - Password_bis',
+ ],
+];
diff --git a/tests/src/Actions/Commands/AbstractCommandActionTest.php b/tests/src/Actions/Commands/AbstractCommandActionTest.php
new file mode 100644
index 0000000..d07243e
--- /dev/null
+++ b/tests/src/Actions/Commands/AbstractCommandActionTest.php
@@ -0,0 +1,58 @@
+acceptsLocales(['en', 'fr'])
+ ->acceptedLocales();
+
+ expect($locales)->toEqual(['en', 'fr']);
+});
+
+it('can be specified a set of groups to accept', function (): void {
+ $groups = (new Action())
+ ->acceptsGroups(['auth', 'pages/home/nav'])
+ ->acceptedGroups();
+
+ expect($groups)->toEqual(['auth', 'pages/home/nav']);
+});
+
+it('can be specified a set of namespaces to accept', function (): void {
+ $locales = (new Action())
+ ->acceptsNamespaces(['package1', 'package2'])
+ ->acceptedNamespaces();
+
+ expect($locales)->toEqual(['package1', 'package2']);
+});
+
+it('can be specified a set of locales to reject', function (): void {
+ $locales = (new Action())
+ ->rejectsLocales(['en', 'fr'])
+ ->rejectedLocales();
+
+ expect($locales)->toEqual(['en', 'fr']);
+});
+
+it('can be specified a set of groups to reject', function (): void {
+ $groups = (new Action())
+ ->rejectsGroups(['auth', 'pages/home/nav'])
+ ->rejectedGroups();
+
+ expect($groups)->toEqual(['auth', 'pages/home/nav']);
+});
+
+it('can be specified a set of namespaces to reject', function (): void {
+ $locales = (new Action())
+ ->rejectsNamespaces(['package1', 'package2'])
+ ->rejectedNamespaces();
+
+ expect($locales)->toEqual(['package1', 'package2']);
+});
diff --git a/tests/src/Actions/Commands/InitCommandActionTest.php b/tests/src/Actions/Commands/InitCommandActionTest.php
new file mode 100644
index 0000000..a930b06
--- /dev/null
+++ b/tests/src/Actions/Commands/InitCommandActionTest.php
@@ -0,0 +1,259 @@
+setBasePath($this->getTestSupportDirectory('.to-delete/InitCommandActionTest'));
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ app(Filesystem::class)->copyDirectory($this->getTestSupportDirectory('lang'), lang_path());
+
+ $this->getTranslationSets = static fn (ProjectConfiguration $project, Branch $branch): Collection => (
+ collect(app($project->drivers->toBase()->keys()->first())->getTranslationSets($project, $branch))
+ );
+
+ PushBatch::setMaxPoolSize(1);
+ PushBatch::setMaxChunkSize(1);
+});
+
+afterEach(function (): void {
+ app(Filesystem::class)->deleteDirectory($this->getTestSupportDirectory('.to-delete'));
+
+ app()->setBasePath($this->getTestSupportDirectory());
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ PushBatch::resetDefaultMaxPoolAndChunkSizes();
+});
+
+it('extends `AbstractCommandAction`', function (): void {
+ expect(is_subclass_of(InitCommandAction::class, AbstractCommandAction::class))->toEqual(true);
+});
+
+it('executes for a given project', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new InitCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->project()->auth_key)->toEqual($project->auth_key);
+
+ Http::assertSentCount($this->getTranslationSets->__invoke($project, $branch)->count() + 3);
+});
+
+it('executes for a given branch', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new InitCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->branch()->name)->toEqual($branch->name);
+
+ Http::assertSentCount($this->getTranslationSets->__invoke($project, $branch)->count() + 3);
+});
+
+it('pushes the translation sets to Transl', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new InitCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $sentTranslationSets = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => $data['chunk']['translation_sets'])
+ ->map(static fn (array $set): TranslationSet => TranslationSet::from($set));
+
+ expect(
+ $translationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ )->toEqual(
+ $sentTranslationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ );
+});
+
+it('uses `PushCommandAction` to push translation sets to Transl', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ app()->singleton(PushCommandAction::class, function (): PushCommandAction {
+ return new class () extends PushCommandAction {
+ public readonly bool $used;
+
+ public function execute(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?PushBatch $batch = null,
+ array $meta = [],
+ ): void {
+ $this->used = true;
+ }
+ };
+ });
+
+ (new InitCommandAction())->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect(app(PushCommandAction::class)->used)->toEqual(true);
+});
+
+it('sets the necessary HTTP headers & custom Transl HTTP headers', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ (new InitCommandAction())->execute($project, $branch);
+
+ $versions = Transl::versions();
+ $packageName = Transl::PACKAGE_NAME;
+
+ $userAgentPackageName = str_replace('/', '___', $packageName);
+ $userAgent = "{$userAgentPackageName}/{$versions->package} laravel/{$versions->laravel} php/{$versions->php}";
+
+ Http::assertSent(static function (Request $request) use ($project, $branch, $versions, $packageName, $userAgent): bool {
+ return $request->hasHeaders([
+ 'User-Agent' => $userAgent,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+
+ 'Authorization' => "Bearer {$project->auth_key}",
+
+ 'X-Transl-Branch-Name' => $branch->name,
+ 'X-Transl-Branch-Provenance' => $branch->provenance(),
+
+ 'X-Transl-Package-Name' => $packageName,
+ 'X-Transl-Package-Version' => $versions->package,
+ 'X-Transl-Framework-Name' => 'Laravel',
+ 'X-Transl-Framework-Version' => $versions->laravel,
+ 'X-Transl-Language-Name' => 'PHP',
+ 'X-Transl-Language-Version' => $versions->php,
+ ]);
+ });
+});
+
+it('sends the necessary data on init start', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ (new InitCommandAction())->execute($project, $branch);
+
+ /** @var Request $startRequest */
+ $startRequest = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_contains($request->url(), '/init/start'))
+ ->first();
+
+ expect($startRequest->data())->toEqual([
+ 'locale' => [
+ 'default' => 'en',
+ 'fallback' => 'en',
+ ],
+ 'branching' => [
+ 'default_branch_name' => 'default',
+ ],
+ ]);
+});
+
+it('sends a fallback default branch if none could be determined', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ Process::fake([
+ Git::getDefaultConfiguredBranchNameCommand() => '',
+ ]);
+
+ config()->set('transl.defaults.project_options.branching.default_branch_name', null);
+
+ Configuration::refreshInstance(config('transl'));
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ (new InitCommandAction())->execute($project, $branch);
+
+ /** @var Request $startRequest */
+ $startRequest = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_contains($request->url(), '/init/start'))
+ ->first();
+
+ expect($startRequest->data()['branching']['default_branch_name'] === Transl::FALLBACK_BRANCH_NAME)->toEqual(true);
+});
diff --git a/tests/src/Actions/Commands/PullCommandActionTest.php b/tests/src/Actions/Commands/PullCommandActionTest.php
new file mode 100644
index 0000000..1432509
--- /dev/null
+++ b/tests/src/Actions/Commands/PullCommandActionTest.php
@@ -0,0 +1,1138 @@
+setBasePath($this->getTestSupportDirectory('.to-delete/PullCommandActionTest'));
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ app(Filesystem::class)->copyDirectory($this->getTestSupportDirectory('lang'), lang_path());
+
+ app()->bind(TranslationSet::class, function (Application $app, array $params): TranslationSet {
+ return new TranslationSet(
+ ...Arr::except($params, ['meta']),
+ meta: [
+ 'translation_file' => [
+ 'full_path' => $this->helpers()
+ ->translationSet()
+ ->determineTranslationFileFullPath(...Arr::except($params, ['lines', 'meta'])),
+ ],
+ ],
+ );
+ });
+
+ File::swap(new class () extends Filesystem {
+ protected array $saved = [];
+
+ public function put($path, $contents, $lock = false)
+ {
+ $this->saved[] = [
+ 'path' => $path,
+ 'contents' => $contents,
+ 'lock' => $lock,
+ ];
+
+ return parent::put($path, $contents, $lock);
+ }
+
+ public function updateTranslationSet(TranslationSet $set, array|string $search, array|string $replace)
+ {
+ $path = $set->meta['translation_file']['full_path'];
+
+ $contents = $this->get($path);
+ $contents = str_replace($search, $replace, $contents);
+
+ return $this->put($path, $contents);
+ }
+
+ public function saved(): array
+ {
+ return $this->saved;
+ }
+ });
+
+ $this->addQueryToUrl = static function (string $url, array $query): string {
+ $url = parse_url($url);
+
+ $existingQuery = !isset($url['query']) ? [] : collect(explode('&', $url['query']))
+ ->reduce(static function (array $acc, string $item): array {
+ [$key, $value] = explode('=', $item);
+
+ $acc[$key] = $value;
+
+ return $acc;
+ }, []);
+
+ $query = [
+ ...$query,
+ ...$existingQuery,
+ ];
+
+ return "{$url['scheme']}://{$url['host']}{$url['path']}?" . http_build_query($query);
+ };
+
+ $this->defaultResponses = json_decode(
+ file_get_contents($this->getFixtureDirectory('pull_stub_responses.json')),
+ true,
+ );
+ $this->defaultResponsesWithFilters = fn (array $query) => (
+ collect($this->defaultResponses)
+ ->reduce(function (array $acc, array $value, string $url) use ($query): array {
+ $url = $this->addQueryToUrl->__invoke($url, $query);
+
+ $acc[$url] = $value;
+
+ return $acc;
+ }, [])
+ );
+
+ $this->pulledTranslationSets = static fn (): Collection => (
+ collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Response)
+ ->map(static fn (Response $response): array => $response->json('data'))
+ ->flatten(1)
+ ->map(static fn (array $set): TranslationSet => TranslationSet::new(
+ locale: $set['attributes']['locale'],
+ group: $set['attributes']['group'],
+ namespace: $set['attributes']['namespace'],
+ lines: TranslationLineCollection::make(
+ array_map(static function (array $line): TranslationLine {
+ return TranslationLine::make(
+ key: $line['attributes']['key'],
+ value: $line['attributes']['value'],
+ meta: $line['meta'],
+ );
+ }, $set['relations']['lines']['data']),
+ ),
+ meta: $set['meta'],
+ ))
+ );
+
+ $this->getTranslationSets = static fn (ProjectConfiguration $project, Branch $branch): Collection => (
+ collect(app($project->drivers->toBase()->keys()->first())->getTranslationSets($project, $branch))
+ );
+
+ $this->trackTranslationSet = static function (ProjectConfiguration $project, Branch $branch, TranslationSet $set): void {
+ $directory = "{$project->options->transl_directory}/{$project->auth_key}/{$branch->name}/tracked";
+ $fullPath = "{$directory}/{$set->trackingKey()}.json";
+
+ app(Filesystem::class)->ensureDirectoryExists(dirname($fullPath));
+
+ app(Filesystem::class)->put("{$directory}/{$set->trackingKey()}.json", json_encode($set->toArray()));
+ };
+});
+
+afterEach(function (): void {
+ app(Filesystem::class)->deleteDirectory($this->getTestSupportDirectory('.to-delete'));
+
+ app()->setBasePath($this->getTestSupportDirectory());
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+});
+
+describe('base', function (): void {
+ it('extends `AbstractCommandAction`', function (): void {
+ expect(is_subclass_of(PullCommandAction::class, AbstractCommandAction::class))->toEqual(true);
+ });
+
+ it('executes for a given project', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->project()->auth_key)->toEqual($project->auth_key);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('executes for a given branch', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->branch()->name)->toEqual($branch->name);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('executes with a given conflict resolution', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+ $conflictResolution = BranchingConflictResolutionEnum::IGNORE;
+
+ $action->execute($project, $branch, $conflictResolution);
+
+ expect($action->conflictResolution())->toEqual($conflictResolution);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('executes with the default project conflict resolution by default', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->conflictResolution())->toEqual($project->options->branching->conflict_resolution);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('pulles the translation sets from Transl', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+
+ expect(
+ $translationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ )->toEqual(
+ $this->pulledTranslationSets->__invoke()
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ );
+ });
+
+ it('pulles the translation sets from Transl & applies the correct formatting', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect(app(Filesystem::class)->saved())->toMatchStandardizedSnapshot();
+ });
+
+ it('saves all locales when no filtering specified', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $locales = $translationSets->pluck('locale')->unique()->sort()->values()->all();
+ $savedLocales = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): string => $path->guessLocale($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($locales)->toEqual($savedLocales);
+ expect($savedLocales)->toEqual(['en', 'fr']);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('saves all groups when no filtering specified', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $savedGroups = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): ?string => $path->guessGroup($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($savedGroups);
+ expect($savedGroups)->toEqual(collect([
+ 'auth',
+ 'email',
+ 'flash',
+ 'value_types',
+ 'pages/dashboard/nav',
+ 'example',
+ null,
+ ])->sort()->values()->all());
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('saves all namespaces when no filtering specified', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $namespaces = $translationSets->pluck('namespace')->unique()->sort()->values()->all();
+ $savedNamespaces = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): ?string => $path->guessNamespace($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($namespaces)->toEqual($savedNamespaces);
+ expect($savedNamespaces)->toEqual([null, 'some_package']);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('only saves the specified locales', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke(['only_locales' => 'fr']));
+
+ $action = (new PullCommandAction())->acceptsLocales(['fr']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => $set->locale === 'fr');
+ $locales = $translationSets->pluck('locale')->unique()->sort()->values()->all();
+ $savedLocales = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): string => $path->guessLocale($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($locales)->toEqual($savedLocales);
+ expect($savedLocales)->toEqual(['fr']);
+
+ Http::assertSentCount(count($this->defaultResponsesWithFilters->__invoke([])));
+ });
+
+ it('only saves the specified groups', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke([
+ 'only_groups' => implode(',', ['auth', null, 'pages/dashboard/nav']),
+ ]));
+
+ $action = (new PullCommandAction())->acceptsGroups(['auth', null, 'pages/dashboard/nav']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => in_array($set->group, ['auth', null, 'pages/dashboard/nav'], true));
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $savedGroups = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): ?string => $path->guessGroup($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($savedGroups);
+ expect($savedGroups)->toEqual(collect([
+ 'auth',
+ 'pages/dashboard/nav',
+ null,
+ ])->sort()->values()->all());
+
+ Http::assertSentCount(count($this->defaultResponsesWithFilters->__invoke([])));
+ });
+
+ it('only saves the specified namespaces', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction())->acceptsNamespaces([null]);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => $set->namespace === null);
+ $namespaces = $translationSets->pluck('namespace')->unique()->sort()->values()->all();
+ $savedNamespaces = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): ?string => $path->guessNamespace($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($namespaces)->toEqual($savedNamespaces);
+ expect($savedNamespaces)->toEqual([null]);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('rejects transfering the specified locales', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke(['except_locales' => 'fr']));
+
+ $action = (new PullCommandAction())->rejectsLocales(['fr']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->reject(static fn (TranslationSet $set): bool => $set->locale === 'fr');
+ $locales = $translationSets->pluck('locale')->unique()->sort()->values()->all();
+ $savedLocales = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): string => $path->guessLocale($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($locales)->toEqual($savedLocales);
+ expect($savedLocales)->toEqual(['en']);
+
+ Http::assertSentCount(count($this->defaultResponsesWithFilters->__invoke([])));
+ });
+
+ it('rejects transfering the specified groups', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke([
+ 'except_groups' => implode(',', ['auth', null, 'pages/dashboard/nav']),
+ ]));
+
+ $action = (new PullCommandAction())->rejectsGroups(['auth', null, 'pages/dashboard/nav']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->reject(static fn (TranslationSet $set): bool => in_array($set->group, ['auth', null, 'pages/dashboard/nav'], true));
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $savedGroups = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): ?string => $path->guessGroup($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($savedGroups);
+ expect($savedGroups)->toEqual(collect([
+ 'email',
+ 'flash',
+ 'value_types',
+ 'example',
+ ])->sort()->values()->all());
+
+ Http::assertSentCount(count($this->defaultResponsesWithFilters->__invoke([])));
+ });
+
+ it('rejects transfering the specified namespaces', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction())->rejectsNamespaces([null]);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->reject(static fn (TranslationSet $set): bool => $set->namespace === null);
+ $namespaces = $translationSets->pluck('namespace')->unique()->sort()->values()->all();
+ $savedNamespaces = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): ?string => $path->guessNamespace($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($namespaces)->toEqual($savedNamespaces);
+ expect($savedNamespaces)->toEqual(['some_package']);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('favors rejecting over accepting specified locales', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke([
+ 'only_locales' => 'fr',
+ 'except_locales' => 'fr',
+ ]));
+
+ $action = (new PullCommandAction())
+ ->acceptsLocales(['fr'])
+ ->rejectsLocales(['fr']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect(empty(app(Filesystem::class)->saved()))->toEqual(true);
+
+ Http::assertSentCount(count($this->defaultResponsesWithFilters->__invoke([])));
+ });
+
+ it('favors rejecting over accepting specified groups', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke([
+ 'only_groups' => implode(',', ['auth', null, 'pages/dashboard/nav', 'example']),
+ 'except_groups' => implode(',', ['auth', null, 'pages/dashboard/nav']),
+ ]));
+
+ $action = (new PullCommandAction())
+ ->acceptsGroups(['auth', null, 'pages/dashboard/nav', 'example'])
+ ->rejectsGroups(['auth', null, 'pages/dashboard/nav']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => $set->group === 'example');
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $savedGroups = collect(app(Filesystem::class)->saved())
+ ->pluck('path')
+ ->map(fn (string $path): LangFilePath => LangFilePath::new(
+ root: $this->getLangDirectory(),
+ relativePath: str($path)->replace(DIRECTORY_SEPARATOR, '/')->after('/lang/')->value(),
+ ))
+ ->map(fn (LangFilePath $path): ?string => $path->guessGroup($this->getLangDirectory()))
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($savedGroups);
+ expect($savedGroups)->toEqual(['example']);
+
+ Http::assertSentCount(count($this->defaultResponsesWithFilters->__invoke([])));
+ });
+
+ it('favors rejecting over accepting specified namespaces', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction())
+ ->acceptsNamespaces([null])
+ ->rejectsNamespaces([null]);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect(empty(app(Filesystem::class)->saved()))->toEqual(true);
+
+ Http::assertSentCount(count($this->defaultResponses));
+ });
+
+ it('notifies of translation sets skipped base on locales filters', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke(['except_locales' => 'fr']));
+
+ $skipped = collect([]);
+
+ $action = (new PullCommandAction())
+ ->rejectsLocales(['fr'])
+ ->onTranslationSetSkipped(static fn (TranslationSet $set) => $skipped->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($skipped->pluck('locale')->unique()->sort()->values()->all())->toEqual(['fr']);
+ });
+
+ it('notifies of translation sets skipped base on groups filters', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke([
+ 'except_groups' => implode(',', ['auth', null, 'pages/dashboard/nav']),
+ ]));
+
+ $skipped = collect([]);
+
+ $action = (new PullCommandAction())
+ ->rejectsGroups(['auth', null, 'pages/dashboard/nav'])
+ ->onTranslationSetSkipped(static fn (TranslationSet $set) => $skipped->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($skipped->pluck('group')->unique()->sort()->values()->all())->toEqual(
+ collect(['auth', null, 'pages/dashboard/nav'])->sort()->values()->all(),
+ );
+ });
+
+ it('notifies of translation sets skipped base on namespaces filters', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $skipped = collect([]);
+
+ $action = (new PullCommandAction())
+ ->rejectsNamespaces([null])
+ ->onTranslationSetSkipped(static fn (TranslationSet $set) => $skipped->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($skipped->pluck('namespace')->unique()->sort()->values()->all())->toEqual([null]);
+ });
+
+ it('notifies of handled translation sets', function (): void {
+ Http::fake($this->defaultResponsesWithFilters->__invoke(['only_locales' => 'fr']));
+
+ $handled = collect([]);
+
+ $action = (new PullCommandAction())
+ ->acceptsLocales(['fr'])
+ ->onTranslationSetHandled(static fn (TranslationSet $set) => $handled->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($handled->pluck('locale')->unique()->sort()->values()->all())->toEqual(['fr']);
+ });
+
+ it('sets the necessary HTTP headers & custom Transl HTTP headers', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ (new PullCommandAction())->execute($project, $branch);
+
+ $versions = Transl::versions();
+ $packageName = Transl::PACKAGE_NAME;
+
+ $userAgentPackageName = str_replace('/', '___', $packageName);
+ $userAgent = "{$userAgentPackageName}/{$versions->package} laravel/{$versions->laravel} php/{$versions->php}";
+
+ Http::assertSent(static function (Request $request) use ($project, $branch, $versions, $packageName, $userAgent): bool {
+ return $request->hasHeaders([
+ 'User-Agent' => $userAgent,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+
+ 'Authorization' => "Bearer {$project->auth_key}",
+
+ 'X-Transl-Branch-Name' => $branch->name,
+ 'X-Transl-Branch-Provenance' => $branch->provenance(),
+
+ 'X-Transl-Package-Name' => $packageName,
+ 'X-Transl-Package-Version' => $versions->package,
+ 'X-Transl-Framework-Name' => 'Laravel',
+ 'X-Transl-Framework-Version' => $versions->laravel,
+ 'X-Transl-Language-Name' => 'PHP',
+ 'X-Transl-Language-Version' => $versions->php,
+ ]);
+ });
+ });
+});
+
+describe('merge & conflict resolution', function (): void {
+ it('notifies of incoming translation set conflicts', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $conflicts = collect([]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action = (new PullCommandAction())->onIncomingTranslationSetConflicts(
+ static fn (TranslationSet $set, TranslationLinesDiffing $diff) => $conflicts->add([$set, $diff]),
+ );
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set): bool {
+ return (
+ $set->locale === 'en'
+ && $set->group === 'auth'
+ && $set->namespace === null
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(static fn () => $action->execute($project, $branch))->toThrow(CouldNotResolveConflictWhilePulling::class);
+
+ expect($conflicts->count())->toEqual(1);
+ expect($conflicts[0][0]->trackingKey())->toEqual($conflictingSet->trackingKey());
+ expect($conflicts[0][1]->conflictingLines()->toArray())->toEqual([$conflictingLine->toArray()]);
+ });
+
+ it('can silence conflict exceptions', function (BranchingConflictResolutionEnum $conflictResolution): void {
+ Http::fake($this->defaultResponses);
+
+ $conflicts = collect([]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action = (new PullCommandAction())
+ ->silenceConflictExceptions()
+ ->onIncomingTranslationSetConflicts(
+ static fn (TranslationSet $set, TranslationLinesDiffing $diff) => $conflicts->add([$set, $diff]),
+ );
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set): bool {
+ return (
+ $set->locale === 'en'
+ && $set->group === 'auth'
+ && $set->namespace === null
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ $action->execute($project, $branch, $conflictResolution);
+
+ expect($conflicts->count())->toEqual(1);
+ expect($conflicts[0][0]->trackingKey())->toEqual($conflictingSet->trackingKey());
+ expect($conflicts[0][1]->conflictingLines()->toArray())->toEqual([$conflictingLine->toArray()]);
+ })->with([BranchingConflictResolutionEnum::THROW, BranchingConflictResolutionEnum::MERGE_BUT_THROW]);
+
+ it('can save previously untracked translation sets', function (): void {
+ Http::fake($this->defaultResponses);
+
+ $action = (new PullCommandAction());
+
+ expect(empty(app(Filesystem::class)->saved()))->toEqual(true);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect(empty(app(Filesystem::class)->saved()))->toEqual(false);
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+
+ expect(
+ $translationSets
+ ->map(static fn (TranslationSet $set): string => $set->meta['translation_file']['full_path'])
+ ->sort()
+ ->values()
+ ->all(),
+ )->toEqual(
+ collect(app(Filesystem::class)->saved())
+ ->map(static fn (array $item): string => $item['path'])
+ ->sort()
+ ->values()
+ ->all(),
+ );
+
+ expect(app(Filesystem::class)->saved())->toMatchStandardizedSnapshot();
+ });
+
+ it('can save while accepting incoming translation sets', function (): void {
+ $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0]['relations']['lines']['data'][] = [
+ 'id' => 'line_99',
+ 'type' => 'translation_line',
+ 'attributes' => [
+ 'id' => 'line_99',
+ 'key' => 'new_key',
+ 'value' => '[ADDED ON INCOMING]',
+ ],
+ 'relations' => [],
+ 'meta' => null,
+ ];
+
+ $incomingSetTarget = $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0];
+
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set) use ($incomingSetTarget): bool {
+ return (
+ $set->locale === $incomingSetTarget['attributes']['locale']
+ && $set->group === $incomingSetTarget['attributes']['group']
+ && $set->namespace === $incomingSetTarget['attributes']['namespace']
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ (new PullCommandAction())->execute($project, $branch, BranchingConflictResolutionEnum::ACCEPT_INCOMING);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(false);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[ADDED ON INCOMING]'),
+ )->toEqual(true);
+ });
+
+ it('can save while accepting current translation sets', function (): void {
+ $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0]['relations']['lines']['data'][] = [
+ 'id' => 'line_99',
+ 'type' => 'translation_line',
+ 'attributes' => [
+ 'id' => 'line_99',
+ 'key' => 'new_key',
+ 'value' => '[ADDED ON INCOMING]',
+ ],
+ 'meta' => null,
+ ];
+
+ $incomingSetTarget = $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0];
+
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set) use ($incomingSetTarget): bool {
+ return (
+ $set->locale === $incomingSetTarget['attributes']['locale']
+ && $set->group === $incomingSetTarget['attributes']['group']
+ && $set->namespace === $incomingSetTarget['attributes']['namespace']
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ (new PullCommandAction())->execute($project, $branch, BranchingConflictResolutionEnum::ACCEPT_CURRENT);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[ADDED ON INCOMING]'),
+ )->toEqual(true);
+ });
+
+ it('can skip saving and throw on conflicts', function (): void {
+ $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0]['relations']['lines'][] = [
+ 'id' => 'line_99',
+ 'type' => 'translation_line',
+ 'attributes' => [
+ 'id' => 'line_99',
+ 'key' => 'new_key',
+ 'value' => '[ADDED ON INCOMING]',
+ ],
+ 'meta' => null,
+ ];
+
+ $incomingSetTarget = $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0];
+
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set) use ($incomingSetTarget): bool {
+ return (
+ $set->locale === $incomingSetTarget['attributes']['locale']
+ && $set->group === $incomingSetTarget['attributes']['group']
+ && $set->namespace === $incomingSetTarget['attributes']['namespace']
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ static fn () => (new PullCommandAction())->execute($project, $branch, BranchingConflictResolutionEnum::THROW),
+ )->toThrow(CouldNotResolveConflictWhilePulling::class);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[ADDED ON INCOMING]'),
+ )->toEqual(false);
+ });
+
+ it('can skip saving and ignore conflicts', function (): void {
+ $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0]['relations']['lines'][] = [
+ 'id' => 'line_99',
+ 'type' => 'translation_line',
+ 'attributes' => [
+ 'id' => 'line_99',
+ 'key' => 'new_key',
+ 'value' => '[ADDED ON INCOMING]',
+ ],
+ 'meta' => null,
+ ];
+
+ $incomingSetTarget = $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0];
+
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set) use ($incomingSetTarget): bool {
+ return (
+ $set->locale === $incomingSetTarget['attributes']['locale']
+ && $set->group === $incomingSetTarget['attributes']['group']
+ && $set->namespace === $incomingSetTarget['attributes']['namespace']
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ (new PullCommandAction())->execute($project, $branch, BranchingConflictResolutionEnum::IGNORE);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[ADDED ON INCOMING]'),
+ )->toEqual(false);
+ });
+
+ it('can save while throwing on conflicts', function (): void {
+ $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0]['relations']['lines']['data'][] = [
+ 'id' => 'line_99',
+ 'type' => 'translation_line',
+ 'attributes' => [
+ 'id' => 'line_99',
+ 'key' => 'new_key',
+ 'value' => '[ADDED ON INCOMING]',
+ ],
+ 'meta' => null,
+ ];
+
+ $incomingSetTarget = $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0];
+
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set) use ($incomingSetTarget): bool {
+ return (
+ $set->locale === $incomingSetTarget['attributes']['locale']
+ && $set->group === $incomingSetTarget['attributes']['group']
+ && $set->namespace === $incomingSetTarget['attributes']['namespace']
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ static fn () => (new PullCommandAction())->execute($project, $branch, BranchingConflictResolutionEnum::MERGE_BUT_THROW),
+ )->toThrow(CouldNotResolveConflictWhilePulling::class);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[ADDED ON INCOMING]'),
+ )->toEqual(true);
+ });
+
+ it('can save while ignoring conflicts', function (): void {
+ $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0]['relations']['lines']['data'][] = [
+ 'id' => 'line_99',
+ 'type' => 'translation_line',
+ 'attributes' => [
+ 'id' => 'line_99',
+ 'key' => 'new_key',
+ 'value' => '[ADDED ON INCOMING]',
+ ],
+ 'meta' => null,
+ ];
+
+ $incomingSetTarget = $this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['data'][0];
+
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set) use ($incomingSetTarget): bool {
+ return (
+ $set->locale === $incomingSetTarget['attributes']['locale']
+ && $set->group === $incomingSetTarget['attributes']['group']
+ && $set->namespace === $incomingSetTarget['attributes']['namespace']
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ (new PullCommandAction())->execute($project, $branch, BranchingConflictResolutionEnum::MERGE_AND_IGNORE);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[ADDED ON INCOMING]'),
+ )->toEqual(true);
+ });
+});
diff --git a/tests/src/Actions/Commands/PushCommandActionTest.php b/tests/src/Actions/Commands/PushCommandActionTest.php
new file mode 100644
index 0000000..9f4c6df
--- /dev/null
+++ b/tests/src/Actions/Commands/PushCommandActionTest.php
@@ -0,0 +1,630 @@
+setBasePath($this->getTestSupportDirectory('.to-delete/PushCommandActionTest'));
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ app(Filesystem::class)->copyDirectory($this->getTestSupportDirectory('lang'), lang_path());
+
+ $this->getTranslationSets = static fn (ProjectConfiguration $project, Branch $branch): Collection => (
+ collect(app($project->drivers->toBase()->keys()->first())->getTranslationSets($project, $branch))
+ );
+
+ PushBatch::setMaxPoolSize(1);
+ PushBatch::setMaxChunkSize(1);
+});
+
+afterEach(function (): void {
+ app(Filesystem::class)->deleteDirectory($this->getTestSupportDirectory('.to-delete'));
+
+ app()->setBasePath($this->getTestSupportDirectory());
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ PushBatch::resetDefaultMaxPoolAndChunkSizes();
+});
+
+it('extends `AbstractCommandAction`', function (): void {
+ expect(is_subclass_of(PushCommandAction::class, AbstractCommandAction::class))->toEqual(true);
+});
+
+it('executes for a given project', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->project()->auth_key)->toEqual($project->auth_key);
+
+ Http::assertSentCount($this->getTranslationSets->__invoke($project, $branch)->count() + 1);
+});
+
+it('executes for a given branch', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->branch()->name)->toEqual($branch->name);
+
+ Http::assertSentCount($this->getTranslationSets->__invoke($project, $branch)->count() + 1);
+});
+
+it('pushes the translation sets to Transl', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $sentTranslationSets = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => $data['chunk']['translation_sets'])
+ ->map(static fn (array $set): TranslationSet => TranslationSet::from($set));
+
+ expect(
+ $translationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ )->toEqual(
+ $sentTranslationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ );
+});
+
+it('transfers all locales when no filtering specified', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $locales = $translationSets->pluck('locale')->unique()->sort()->values()->all();
+ $sentLocales = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('locale')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($locales)->toEqual($sentLocales);
+ expect($sentLocales)->toEqual(['en', 'fr']);
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('transfers all groups when no filtering specified', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $sentGroups = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('group')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($sentGroups);
+ expect($sentGroups)->toEqual(collect([
+ 'auth',
+ 'email',
+ 'flash',
+ 'value_types',
+ 'pages/dashboard/nav',
+ 'example',
+ null,
+ ])->sort()->values()->all());
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('transfers all namespaces when no filtering specified', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction());
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+ $namespaces = $translationSets->pluck('namespace')->unique()->sort()->values()->all();
+ $sentNamespaces = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('namespace')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($namespaces)->toEqual($sentNamespaces);
+ expect($sentNamespaces)->toEqual([null, 'some_package']);
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('only transfers the specified locales', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())->acceptsLocales(['fr']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => $set->locale === 'fr');
+ $locales = $translationSets->pluck('locale')->unique()->sort()->values()->all();
+ $sentLocales = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('locale')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($locales)->toEqual($sentLocales);
+ expect($sentLocales)->toEqual(['fr']);
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('only transfers the specified groups', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())->acceptsGroups(['auth', null, 'pages/dashboard/nav']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => in_array($set->group, ['auth', null, 'pages/dashboard/nav'], true));
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $sentGroups = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('group')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($sentGroups);
+ expect($sentGroups)->toEqual(collect([
+ 'auth',
+ 'pages/dashboard/nav',
+ null,
+ ])->sort()->values()->all());
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('only transfers the specified namespaces', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())->acceptsNamespaces([null]);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => $set->namespace === null);
+ $namespaces = $translationSets->pluck('namespace')->unique()->sort()->values()->all();
+ $sentNamespaces = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('namespace')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($namespaces)->toEqual($sentNamespaces);
+ expect($sentNamespaces)->toEqual([null]);
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('rejects transfering the specified locales', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())->rejectsLocales(['fr']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->reject(static fn (TranslationSet $set): bool => $set->locale === 'fr');
+ $locales = $translationSets->pluck('locale')->unique()->sort()->values()->all();
+ $sentLocales = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('locale')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($locales)->toEqual($sentLocales);
+ expect($sentLocales)->toEqual(['en']);
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('rejects transfering the specified groups', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())->rejectsGroups(['auth', null, 'pages/dashboard/nav']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->reject(static fn (TranslationSet $set): bool => in_array($set->group, ['auth', null, 'pages/dashboard/nav'], true));
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $sentGroups = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('group')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($sentGroups);
+ expect($sentGroups)->toEqual(collect([
+ 'email',
+ 'flash',
+ 'value_types',
+ 'example',
+ ])->sort()->values()->all());
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('rejects transfering the specified namespaces', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())->rejectsNamespaces([null]);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->reject(static fn (TranslationSet $set): bool => $set->namespace === null);
+ $namespaces = $translationSets->pluck('namespace')->unique()->sort()->values()->all();
+ $sentNamespaces = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('namespace')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($namespaces)->toEqual($sentNamespaces);
+ expect($sentNamespaces)->toEqual(['some_package']);
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('favors rejecting over accepting specified locales', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())
+ ->acceptsLocales(['fr'])
+ ->rejectsLocales(['fr']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ Http::assertNothingSent();
+});
+
+it('favors rejecting over accepting specified groups', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())
+ ->acceptsGroups(['auth', null, 'pages/dashboard/nav', 'example'])
+ ->rejectsGroups(['auth', null, 'pages/dashboard/nav']);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch())
+ ->filter(static fn (TranslationSet $set): bool => $set->group === 'example');
+ $groups = $translationSets->pluck('group')->unique()->sort()->values()->all();
+ $sentGroups = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => collect($data['chunk']['translation_sets'])->pluck('group')->all())
+ ->unique()
+ ->sort()
+ ->values()
+ ->all();
+
+ expect($groups)->toEqual($sentGroups);
+ expect($sentGroups)->toEqual(['example']);
+
+ Http::assertSentCount($translationSets->count() + 1);
+});
+
+it('favors rejecting over accepting specified namespaces', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction())
+ ->acceptsNamespaces([null])
+ ->rejectsNamespaces([null]);
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ Http::assertNothingSent();
+});
+
+it('notifies of translation sets skipped base on locales filters', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $skipped = collect([]);
+
+ $action = (new PushCommandAction())
+ ->rejectsLocales(['fr'])
+ ->onTranslationSetSkipped(static fn (TranslationSet $set) => $skipped->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($skipped->pluck('locale')->unique()->sort()->values()->all())->toEqual(['fr']);
+});
+
+it('notifies of translation sets skipped base on groups filters', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $skipped = collect([]);
+
+ $action = (new PushCommandAction())
+ ->rejectsGroups(['auth', null, 'pages/dashboard/nav'])
+ ->onTranslationSetSkipped(static fn (TranslationSet $set) => $skipped->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($skipped->pluck('group')->unique()->sort()->values()->all())->toEqual(
+ collect(['auth', null, 'pages/dashboard/nav'])->sort()->values()->all(),
+ );
+});
+
+it('notifies of translation sets skipped base on namespaces filters', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $skipped = collect([]);
+
+ $action = (new PushCommandAction())
+ ->rejectsNamespaces([null])
+ ->onTranslationSetSkipped(static fn (TranslationSet $set) => $skipped->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($skipped->pluck('namespace')->unique()->sort()->values()->all())->toEqual([null]);
+});
+
+it('notifies of handled translation sets', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $handled = collect([]);
+
+ $action = (new PushCommandAction())
+ ->acceptsLocales(['fr'])
+ ->onTranslationSetHandled(static fn (TranslationSet $set) => $handled->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($handled->pluck('locale')->unique()->sort()->values()->all())->toEqual(['fr']);
+});
+
+it('tracks handled translation sets', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $action = (new PushCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $directory = "{$project->options->transl_directory}/{$project->auth_key}/{$branch->name}/tracked";
+
+ expect(file_exists($directory))->toEqual(false);
+
+ $action->execute($project, $branch);
+
+ expect(file_exists($directory))->toEqual(true);
+
+ $translationSets = $this->getTranslationSets->__invoke($action->project(), $action->branch());
+
+ foreach ($translationSets as $set) {
+ expect(file_exists("{$directory}/{$set->trackingKey()}.json"))->toEqual(true);
+ }
+});
+
+it('does not track skipped translation sets', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $skipped = collect([]);
+ $handled = collect([]);
+
+ $action = (new PushCommandAction())
+ ->rejectsLocales(['fr'])
+ ->onTranslationSetSkipped(static fn (TranslationSet $set) => $skipped->add($set))
+ ->onTranslationSetHandled(static fn (TranslationSet $set) => $handled->add($set));
+
+ $action->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($skipped->isEmpty())->toEqual(false);
+ expect($handled->isEmpty())->toEqual(false);
+
+ $directory = "{$action->project()->options->transl_directory}/{$action->project()->auth_key}/{$action->branch()->name}/tracked";
+
+ foreach ($skipped as $set) {
+ expect(file_exists("{$directory}/{$set->trackingKey()}.json"))->toEqual(false);
+ }
+
+ foreach ($handled as $set) {
+ expect(file_exists("{$directory}/{$set->trackingKey()}.json"))->toEqual(true);
+ }
+});
+
+it('sets the necessary HTTP headers & custom Transl HTTP headers', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ (new PushCommandAction())->execute($project, $branch);
+
+ $versions = Transl::versions();
+ $packageName = Transl::PACKAGE_NAME;
+
+ $userAgentPackageName = str_replace('/', '___', $packageName);
+ $userAgent = "{$userAgentPackageName}/{$versions->package} laravel/{$versions->laravel} php/{$versions->php}";
+
+ Http::assertSent(static function (Request $request) use ($project, $branch, $versions, $packageName, $userAgent): bool {
+ return $request->hasHeaders([
+ 'User-Agent' => $userAgent,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+
+ 'Authorization' => "Bearer {$project->auth_key}",
+
+ 'X-Transl-Branch-Name' => $branch->name,
+ 'X-Transl-Branch-Provenance' => $branch->provenance(),
+
+ 'X-Transl-Package-Name' => $packageName,
+ 'X-Transl-Package-Version' => $versions->package,
+ 'X-Transl-Framework-Name' => 'Laravel',
+ 'X-Transl-Framework-Version' => $versions->laravel,
+ 'X-Transl-Language-Name' => 'PHP',
+ 'X-Transl-Language-Version' => $versions->php,
+ ]);
+ });
+});
diff --git a/tests/src/Actions/Commands/SynchCommandActionTest.php b/tests/src/Actions/Commands/SynchCommandActionTest.php
new file mode 100644
index 0000000..554e845
--- /dev/null
+++ b/tests/src/Actions/Commands/SynchCommandActionTest.php
@@ -0,0 +1,110 @@
+bind(PullCommandAction::class, function (): PullCommandAction {
+ return new class () extends PullCommandAction {
+ public function execute(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?BranchingConflictResolutionEnum $conflictResolution = null,
+ ): void {
+ //
+ }
+ };
+ });
+ app()->bind(PushCommandAction::class, function (): PushCommandAction {
+ return new class () extends PushCommandAction {
+ public function execute(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?PushBatch $batch = null,
+ array $meta = [],
+ ): void {
+ //
+ }
+ };
+ });
+});
+
+it('extends `AbstractCommandAction`', function (): void {
+ expect(is_subclass_of(PullCommandAction::class, AbstractCommandAction::class))->toEqual(true);
+});
+
+it('executes for a given project', function (): void {
+ $action = (new SynchCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->project()->auth_key)->toEqual($project->auth_key);
+});
+
+it('executes for a given branch', function (): void {
+ $action = (new SynchCommandAction());
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $action->execute($project, $branch);
+
+ expect($action->branch()->name)->toEqual($branch->name);
+});
+
+it('uses `PullCommandAction` to pull translation sets from Transl', function (): void {
+ $pullAction = app(PullCommandAction::class);
+
+ $used = false;
+
+ app()->bind(PullCommandAction::class, function () use (&$used, $pullAction): PullCommandAction {
+ $used = true;
+
+ return $pullAction;
+ });
+
+ (new SynchCommandAction())->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($used)->toEqual(true);
+});
+
+it('uses `PushCommandAction` to push translation sets to Transl', function (): void {
+ $pushAction = app(PushCommandAction::class);
+
+ $used = false;
+
+ app()->bind(PushCommandAction::class, function () use (&$used, $pushAction): PushCommandAction {
+ $used = true;
+
+ return $pushAction;
+ });
+
+ (new SynchCommandAction())->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect($used)->toEqual(true);
+});
+
+test('misc.', function (): void {
+ (new SynchCommandAction())
+ ->onPulledTranslationSetSkipped(static fn () => null)
+ ->onPulledTranslationSetHandled(static fn () => null)
+ ->onPushedTranslationSetSkipped(static fn () => null)
+ ->onPushedTranslationSetHandled(static fn () => null)
+ ->onIncomingTranslationSetConflicts(static fn () => null)
+ ->silenceConflictExceptions()
+ ->execute(Transl::config()->projects()->first(), Branch::asCurrent('yolo'));
+
+ expect(true)->toEqual(true);
+});
diff --git a/tests/src/Actions/LocalFilesDriver/GetTrackedTranslationSetFromLocalFilesActionTest.php b/tests/src/Actions/LocalFilesDriver/GetTrackedTranslationSetFromLocalFilesActionTest.php
new file mode 100644
index 0000000..462940f
--- /dev/null
+++ b/tests/src/Actions/LocalFilesDriver/GetTrackedTranslationSetFromLocalFilesActionTest.php
@@ -0,0 +1,86 @@
+set('transl.defaults.project_options.transl_directory', false);
+
+ Configuration::setInstance(Configuration::new(config('transl')));
+
+ $translationSet = TranslationSet::new(
+ locale: 'en',
+ group: 'test',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $isntance = (new GetTrackedTranslationSetFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute($translationSet);
+
+ expect($result)->toEqual(null);
+
+ Configuration::setInstance($previousConfiguration);
+});
+
+it('returns "null" when the provided TranslationSet is not yet tracked', function (): void {
+ $translationSet = TranslationSet::new(
+ locale: 'en',
+ group: 'test',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $isntance = (new GetTrackedTranslationSetFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute($translationSet);
+
+ expect($result)->toEqual(null);
+});
+
+it('works', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('test');
+
+ $translDirectory = config('transl.defaults.project_options.transl_directory');
+
+ $translationSet = TranslationSet::new(
+ locale: 'en',
+ group: 'test',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $path = "{$translDirectory}/{$project->auth_key}/{$branch->name}/tracked/{$translationSet->trackingKey()}.json";
+
+ app(Filesystem::class)->ensureDirectoryExists(dirname($path));
+ app(Filesystem::class)->put($path, json_encode($translationSet->toArray()));
+
+ $isntance = (new GetTrackedTranslationSetFromLocalFilesAction())
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute($translationSet);
+
+ expect($result->toArray())->toEqual($translationSet->toArray());
+
+ app(Filesystem::class)->deleteDirectory($translDirectory);
+});
diff --git a/tests/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest.php b/tests/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest.php
new file mode 100644
index 0000000..c48add7
--- /dev/null
+++ b/tests/src/Actions/LocalFilesDriver/GetTranslationContentsFromLocalFilesActionTest.php
@@ -0,0 +1,211 @@
+allFiles($this->getLangDirectory());
+ $files = app(Filesystem::class)->allFiles(dirname(__DIR__, 3) . '/TestSupport/lang');
+
+ foreach ($files as $file) {
+ $relativePath = str($file->getRelativePathname())->replace(DIRECTORY_SEPARATOR, '/');
+
+ $locale = $relativePath->before('/')->before('.')->value();
+ $group = $relativePath->after('/')->before('.')->value();
+ $namespace = null;
+
+ if ($relativePath->contains('vendor/')) {
+ $namespace = $relativePath->after('vendor/')->before('/')->value();
+ $locale = $relativePath->after("vendor/{$namespace}/")->before('/')->before('.')->value();
+ $group = $relativePath->after("vendor/{$namespace}/")->after('/')->before('.')->value();
+ }
+
+ if ($group === $locale) {
+ $group = null;
+ }
+
+ $testName = $namespace ? "[{$locale}] - {$namespace}::" : "[{$locale}] - ";
+ $testName = $group ? "{$testName}{$group}" : "{$testName}[JSON file]";
+
+ test($testName, function () use ($locale, $group, $namespace): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute($locale, $group, $namespace);
+
+ expect($result)->toMatchSnapshot();
+ });
+ }
+});
+
+it('loads the JSON files when no group or namespace are given', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', null, null);
+
+ expect($result)->toEqual(app(Filesystem::class)->json($this->getLangDirectory('en.json')));
+});
+
+it('throws an exception when no group is given but a namespace is given', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+
+ expect(static fn () => $isntance->execute('en', null, 'yolo'))->toThrow(MissingRequiredTranslationSetGroup::class);
+});
+
+test('en/pages/dashboard/nav', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'pages/dashboard/nav', null);
+
+ expect($result)->toEqual(__('pages/dashboard/nav', [], 'en'));
+});
+
+test('en/auth', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'auth', null);
+
+ expect($result)->toEqual(__('auth', [], 'en'));
+});
+
+test('en/email', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'email', null);
+
+ expect($result)->toEqual(__('email', [], 'en'));
+});
+
+test('en/flash', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'flash', null);
+
+ expect($result)->toEqual(__('flash', [], 'en'));
+});
+
+test('en/value_types', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'value_types', null);
+
+ expect($result)->toEqual(__('value_types', [], 'en'));
+});
+
+test('vendor/some_package/en/pages/dashboard/nav', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'pages/dashboard/nav', 'some_package');
+
+ expect($result)->toEqual(__('some_package::pages/dashboard/nav', [], 'en'));
+});
+
+test('vendor/some_package/en/auth', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'auth', 'some_package');
+
+ expect($result)->toEqual(__('some_package::auth', [], 'en'));
+});
+
+test('vendor/some_package/en/example', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'example', 'some_package');
+
+ expect($result)->toEqual(__('some_package::example', [], 'en'));
+});
+
+test('en.json', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', null, null);
+
+ expect(Arr::get($result, 'Hello'))->toEqual(__('Hello', [], 'en'));
+ expect(Arr::get($result, 'pages.dashboard.nav.users.billing'))->toEqual(__('pages.dashboard.nav.users.billing', [], 'en'));
+
+ expect(Arr::get($result, 'pages.dashboard.nav.users.logout'))->toEqual("[JSON] overriden 'pages.dashboard.nav.users.logout'!");
+ expect(__('pages.dashboard.nav.users.logout', [], 'en'))->toEqual('pages.dashboard.nav.users.logout');
+
+ expect(Arr::get($result, 'null'))->toEqual(null);
+ expect(__('null', [], 'en'))->toEqual('null');
+
+ expect(Arr::get($result, 'string'))->toEqual(__('string', [], 'en'));
+ expect(Arr::get($result, 'true'))->toEqual(__('true', [], 'en'));
+
+ expect(Arr::get($result, 'false'))->toEqual(false);
+ expect(__('false', [], 'en'))->toEqual('false');
+
+ expect(Arr::get($result, 'int'))->toEqual(__('int', [], 'en'));
+ expect(Arr::get($result, 'float'))->toEqual(__('float', [], 'en'));
+ expect(Arr::get($result, 'string_null'))->toEqual(__('string_null', [], 'en'));
+ expect(Arr::get($result, 'string_true'))->toEqual(__('string_true', [], 'en'));
+ expect(Arr::get($result, 'string_false'))->toEqual(__('string_false', [], 'en'));
+ expect(Arr::get($result, 'string_int'))->toEqual(__('string_int', [], 'en'));
+ expect(Arr::get($result, 'string_float'))->toEqual(__('string_float', [], 'en'));
+
+ expect(Arr::get($result, 'string_empty'))->toEqual('');
+ expect(__('string_empty', [], 'en'))->toEqual('string_empty');
+
+ expect(Arr::get($result, 'array_empty'))->toEqual([]);
+ expect(__('array_empty', [], 'en'))->toEqual('array_empty');
+});
+
+test('fr/pages/dashboard/nav', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('fr', 'pages/dashboard/nav', null);
+
+ expect($result)->toEqual((new LocalFilesDriver())->translationLoader()->load('fr', 'pages/dashboard/nav', null));
+ expect($result)->toEqual([
+ 'users' => [
+ 'profile' => '[FR] Profile',
+ 'billing' => '[FR] Billing',
+ 'password' => '[FR] Password',
+ 'logout' => '[FR] Log out',
+ ],
+ ]);
+ expect(__('pages/dashboard/nav', [], 'fr'))->toEqual([
+ 'users' => [
+ 'logout' => "[FR][JSON][bis] overriden 'pages.dashboard.nav.users.logout'!",
+ ],
+ ]);
+});
+
+test('fr/auth', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('fr', 'auth', null);
+
+ expect($result)->toEqual(__('auth', [], 'fr'));
+});
+
+test('vendor/some_package/fr/pages/dashboard/nav', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('fr', 'pages/dashboard/nav', 'some_package');
+
+ expect($result)->toEqual(__('some_package::pages/dashboard/nav', [], 'fr'));
+});
+
+test('fr.json', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('fr', null, null);
+
+ expect(Arr::get($result, 'Hello'))->toEqual(__('Hello', [], 'fr'));
+ expect(Arr::get($result, 'pages.dashboard.nav.users.billing'))->toEqual(__('pages.dashboard.nav.users.billing', [], 'fr'));
+
+ expect(Arr::get($result, 'pages.dashboard.nav.users.logout'))->toEqual("[FR][JSON] overriden 'pages.dashboard.nav.users.logout'!");
+ expect(__('pages.dashboard.nav.users.logout', [], 'fr'))->toEqual('pages.dashboard.nav.users.logout');
+});
+
+test('vendor/laravel/framework/src/Illuminate/Translation/lang/en/pagination', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'pagination', null);
+
+ expect($result)->toEqual(__('pagination', [], 'en'));
+});
+
+test('vendor/laravel/framework/src/Illuminate/Translation/lang/en/passwords', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'passwords', null);
+
+ expect($result)->toEqual(__('passwords', [], 'en'));
+});
+
+test('vendor/laravel/framework/src/Illuminate/Translation/lang/en/validation', function (): void {
+ $isntance = (new GetTranslationContentsFromLocalFilesAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute('en', 'validation', null);
+
+ expect($result)->toEqual(__('validation', [], 'en'));
+});
diff --git a/tests/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesActionTest.php b/tests/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesActionTest.php
new file mode 100644
index 0000000..479154c
--- /dev/null
+++ b/tests/src/Actions/LocalFilesDriver/GetTranslationSetsFromLocalFilesActionTest.php
@@ -0,0 +1,166 @@
+allFiles($this->getLangDirectory()));
+ $isntance = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->shouldIgnorePackageTranslations(false)
+ ->shouldIgnoreVendorTranslations(false);
+ $result = $isntance->execute();
+
+ expect($result instanceof Generator)->toEqual(true);
+
+ /** @var TranslationSet[] $sets */
+ $sets = [];
+
+ foreach ($result as $set) {
+ expect($set instanceof TranslationSet)->toEqual(true);
+
+ $sets[] = $set;
+
+ $translationFileRelativePath = $this->helpers()->translationSet()->determineTranslationFileRelativePath($set);
+ $translationFileFullPath = "{$this->getLangDirectory()}/{$translationFileRelativePath}";
+
+ expect(file_exists($translationFileFullPath))->toEqual(true);
+ expect(is_file($translationFileFullPath))->toEqual(true);
+
+ $files = $files->filter(static function (SplFileInfo $file) use ($translationFileFullPath): bool {
+ return $translationFileFullPath !== str_replace(DIRECTORY_SEPARATOR, '/', $file->getRealPath());
+ });
+ }
+
+ expect($files->isEmpty())->toEqual(true);
+
+ expect(
+ collect($sets)->sortBy(fn (TranslationSet $set) => $set->trackingKey())->values()->all(),
+ )->toMatchStandardizedSnapshot();
+});
+
+it('throws an exception when the provided language directory cannot be opened', function (): void {
+ $isntance = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories(['nope'])
+ ->shouldIgnorePackageTranslations(false)
+ ->shouldIgnoreVendorTranslations(false);
+ $result = $isntance->execute();
+
+ expect(static fn () => iterator_to_array($result))->toThrow(CouldNotOpenLanguageDirectory::class);
+});
+
+it('can ignore package translation files', function (): void {
+ $isntance = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->shouldIgnorePackageTranslations(true)
+ ->shouldIgnoreVendorTranslations(false);
+ $result = $isntance->execute();
+
+ $namespacedSets = collect($result)->filter(static function (TranslationSet $set): bool {
+ return (bool) $set->namespace;
+ });
+
+ expect($namespacedSets->isEmpty())->toEqual(true);
+});
+
+it('can ignore vendor translation files', function (): void {
+ $isntance = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getPackageDirectory('/vendor/laravel/framework/src/Illuminate/Translation/lang')])
+ ->shouldIgnorePackageTranslations(false)
+ ->shouldIgnoreVendorTranslations(false);
+ $result = $isntance->execute();
+
+ expect(collect($result)->isEmpty())->toEqual(false);
+
+ $result = $isntance->shouldIgnoreVendorTranslations(true)->execute();
+
+ expect(collect($result)->isEmpty())->toEqual(true);
+});
+
+it('can throw an exception when a disallowed locale is encountered', function (): void {
+ $previousConfiguration = Configuration::setInstance(Configuration::new(config('transl')));
+
+ config()->set('transl.defaults.project_options.locale.allowed', ['de']);
+ config()->set('transl.defaults.project_options.locale.throw_on_disallowed_locale', true);
+
+ Configuration::setInstance(Configuration::new(config('transl')));
+
+ $isntance = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->shouldIgnorePackageTranslations(false)
+ ->shouldIgnoreVendorTranslations(false);
+ $result = $isntance->execute();
+
+ expect(static fn () => iterator_to_array($result))->toThrow(FoundDisallowedProjectLocale::class);
+
+ Configuration::setInstance($previousConfiguration);
+});
+
+it('can ignore encountered disallowed locales', function (): void {
+ $previousConfiguration = Configuration::setInstance(Configuration::new(config('transl')));
+
+ config()->set('transl.defaults.project_options.locale.allowed', ['de']);
+ config()->set('transl.defaults.project_options.locale.throw_on_disallowed_locale', false);
+
+ Configuration::setInstance(Configuration::new(config('transl')));
+
+ $isntance = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->shouldIgnorePackageTranslations(false)
+ ->shouldIgnoreVendorTranslations(false);
+ $result = $isntance->execute();
+
+ expect(collect($result)->isEmpty())->toEqual(true);
+
+ Configuration::setInstance($previousConfiguration);
+});
+
+it("doesn't ignore encountered allowed locales", function (): void {
+ $previousConfiguration = Configuration::setInstance(Configuration::new(config('transl')));
+
+ config()->set('transl.defaults.project_options.locale.allowed', ['fr']);
+ config()->set('transl.defaults.project_options.locale.throw_on_disallowed_locale', false);
+
+ Configuration::setInstance(Configuration::new(config('transl')));
+
+ $isntance = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->shouldIgnorePackageTranslations(false)
+ ->shouldIgnoreVendorTranslations(false);
+ $result = $isntance->execute();
+
+ expect(collect($result)->pluck('locale')->unique()->values()->all())->toEqual(['fr']);
+
+ Configuration::setInstance($previousConfiguration);
+});
diff --git a/tests/src/Actions/LocalFilesDriver/SaveTrackedTranslationSetToLocalFilesActionTest.php b/tests/src/Actions/LocalFilesDriver/SaveTrackedTranslationSetToLocalFilesActionTest.php
new file mode 100644
index 0000000..688050f
--- /dev/null
+++ b/tests/src/Actions/LocalFilesDriver/SaveTrackedTranslationSetToLocalFilesActionTest.php
@@ -0,0 +1,78 @@
+set('transl.defaults.project_options.transl_directory', false);
+
+ Configuration::setInstance(Configuration::new(config('transl')));
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('test');
+
+ $translationSet = TranslationSet::new(
+ locale: 'en',
+ group: 'test',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $path = "{$translDirectory}/{$project->auth_key}/{$branch->name}/tracked/{$translationSet->trackingKey()}.json";
+
+ $isntance = (new SaveTrackedTranslationSetToLocalFilesAction())
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingDriver(new LocalFilesDriver());
+
+ $isntance->execute($translationSet);
+
+ expect(file_exists($path))->toEqual(false);
+
+ Configuration::setInstance($previousConfiguration);
+});
+
+it('works', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('test');
+
+ $translDirectory = config('transl.defaults.project_options.transl_directory');
+
+ $translationSet = TranslationSet::new(
+ locale: 'en',
+ group: 'test',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $path = "{$translDirectory}/{$project->auth_key}/{$branch->name}/tracked/{$translationSet->trackingKey()}.json";
+
+ $isntance = (new SaveTrackedTranslationSetToLocalFilesAction())
+ ->usingProject($project)
+ ->usingBranch($branch)
+ ->usingDriver(new LocalFilesDriver());
+
+ expect(file_exists($path))->toEqual(false);
+
+ $isntance->execute($translationSet);
+
+ expect(file_exists($path))->toEqual(true);
+
+ expect(app(Filesystem::class)->json($path))->toEqual($translationSet->toArray());
+
+ app(Filesystem::class)->deleteDirectory($translDirectory);
+});
diff --git a/tests/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesActionTest.php b/tests/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesActionTest.php
new file mode 100644
index 0000000..9106951
--- /dev/null
+++ b/tests/src/Actions/LocalFilesDriver/SaveTranslationSetToLocalFilesActionTest.php
@@ -0,0 +1,136 @@
+saved[] = [
+ 'path' => $path,
+ 'contents' => $contents,
+ 'lock' => $lock,
+ ];
+
+ // return parent::put($path, $contents, $lock);
+
+ return 123;
+ }
+
+ public function saved(): array
+ {
+ return $this->saved;
+ }
+ });
+});
+
+it('works', function (): void {
+ $files = collect(app(Filesystem::class)->allFiles($this->getLangDirectory()));
+
+ $sets = (new GetTranslationSetsFromLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->shouldIgnorePackageTranslations(false)
+ ->shouldIgnoreVendorTranslations(false)
+ ->execute();
+
+ foreach ($sets as $set) {
+ (new SaveTranslationSetToLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->usingTranslationSet($set)
+ ->execute();
+ }
+
+ /** @var array $saved */
+ $saved = app(Filesystem::class)->saved();
+ $savedPaths = collect($saved)->pluck('path')->map(static function (string $path): string {
+ return str_replace(DIRECTORY_SEPARATOR, '/', $path);
+ });
+
+ $files = $files->filter(static function (SplFileInfo $file) use ($savedPaths): bool {
+ $fullPath = str_replace(DIRECTORY_SEPARATOR, '/', $file->getRealPath());
+
+ return !$savedPaths->contains($fullPath);
+ });
+
+ expect($files->isEmpty())->toEqual(true);
+
+ expect(app(Filesystem::class)->saved())->toMatchStandardizedSnapshot();
+});
+
+it("can default to the first defined default language directories", function (): void {
+ $set = TranslationSet::new(
+ locale: 'ht',
+ group: null,
+ namespace: null,
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $driver = new class () extends LocalFilesDriver {
+ public function defaultLanguageDirectories(ProjectConfiguration $project, Branch $branch): array
+ {
+ return [base_path('should_be_choosen'), base_path('nope')];
+ }
+ };
+
+ $instance = (new SaveTranslationSetToLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver($driver)
+ ->usingLanguageDirectories([])
+ ->usingTranslationSet($set);
+
+ $instance->execute();
+
+ $savedPaths = collect(app(Filesystem::class)->saved())->pluck('path')->map(static function (string $path): string {
+ return str_replace(DIRECTORY_SEPARATOR, '/', $path);
+ });
+
+ expect($savedPaths->count())->toEqual(1);
+ expect($savedPaths->first())->toEqual($this->getTestSupportDirectory('should_be_choosen/ht.json'));
+});
+
+it("throws an exception when it cannot determine the translation file's relative path", function (): void {
+ $set = TranslationSet::new(
+ locale: 'ht',
+ group: null,
+ namespace: 'yolo',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $instance = (new SaveTranslationSetToLocalFilesAction())
+ ->usingProject(Transl::config()->projects()->first())
+ ->usingBranch(Branch::asCurrent('test'))
+ ->usingDriver(new LocalFilesDriver())
+ ->usingLanguageDirectories([$this->getLangDirectory()])
+ ->usingTranslationSet($set);
+
+ expect(static fn () => $instance->execute())->toThrow(CouldNotDetermineTranslationFileRelativePathFromTranslationSet::class);
+});
diff --git a/tests/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetTest.php b/tests/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetTest.php
new file mode 100644
index 0000000..6dd2502
--- /dev/null
+++ b/tests/src/Actions/LocalFilesDriver/TranslationContentsToTranslationSetTest.php
@@ -0,0 +1,40 @@
+ __('auth', [], 'en'),
+ 'locale' => 'en',
+ 'group' => 'auth',
+ 'namespace' => null,
+ 'meta' => [
+ 'some_metadata' => 'some_value',
+ ],
+ ];
+
+ $lines = TranslationLineCollection::fromRawTranslationLines($data['contents']);
+
+ $isntance = (new TranslationContentsToTranslationSetAction())->usingDriver(new LocalFilesDriver());
+ $result = $isntance->execute(...$data);
+
+ expect($result instanceof TranslationSet)->toEqual(true);
+
+ expect($result->lines)->toEqual($lines);
+ expect($result->locale)->toEqual($data['locale']);
+ expect($result->group)->toEqual($data['group']);
+ expect($result->namespace)->toEqual($data['namespace']);
+ expect($result->meta)->toEqual($data['meta']);
+ expect($result->toArray())->toEqual([
+ ...Arr::except($data, 'contents'),
+ 'lines' => $lines->toArray(),
+ ]);
+
+ expect($result)->toMatchSnapshot();
+});
diff --git a/tests/src/Actions/Reports/SendMissingTranslationKeyReportActionTest.php b/tests/src/Actions/Reports/SendMissingTranslationKeyReportActionTest.php
new file mode 100644
index 0000000..a5d0d18
--- /dev/null
+++ b/tests/src/Actions/Reports/SendMissingTranslationKeyReportActionTest.php
@@ -0,0 +1,217 @@
+set('app.debug', true);
+
+ $this->translConfig = config('transl');
+
+ config()->set('transl.projects', [
+ [
+ 'auth_key' => 'project1',
+ ],
+ [
+ 'auth_key' => 'project2',
+ ],
+ ]);
+
+ Configuration::refreshInstance(config('transl'));
+});
+
+afterEach(function (): void {
+ config()->set('transl', $this->translConfig);
+
+ Configuration::refreshInstance(config('transl'));
+});
+
+it('works', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/reports/missing-translation-keys' => Http::response(),
+ ]);
+
+ $projects = Transl::config()->projects();
+ $branch1 = Branch::asCurrent('yolo');
+ $branch2 = Branch::asFallback('yo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKey2 = MissingTranslationKey::new('auth.password', [], 'ht', true);
+ $missingKeyReport1 = MissingTranslationKeyReport::new($projects->first(), $branch1, $missingKey);
+ $missingKeyReport2 = MissingTranslationKeyReport::new($projects->last(), $branch1, $missingKey);
+ $missingKeyReport3 = MissingTranslationKeyReport::new($projects->first(), $branch2, $missingKey);
+ $missingKeyReport4 = MissingTranslationKeyReport::new($projects->last(), $branch2, $missingKey);
+ $missingKeyReport5 = MissingTranslationKeyReport::new($projects->first(), $branch2, $missingKey2);
+
+ $missingKeys = (new MissingTranslationKeys())
+ ->add('auth.password', [], 'en', true, $projects->first(), $branch1)
+ ->add('auth.password', [], 'en', true, $projects->first(), $branch1)
+ ->add('auth.password', [], 'en', true, $projects->last(), $branch1)
+ ->add('auth.password', [], 'en', true, $projects->first(), $branch2)
+ ->add('auth.password', [], 'en', true, $projects->last(), $branch2)
+ ->add('auth.password', [], 'ht', true, $projects->first(), $branch2);
+
+ (new SendMissingTranslationKeyReportAction())->execute($missingKeys->queued());
+
+ Http::assertSentCount(4);
+
+ $sent = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->map(static fn (Request $request): array => $request->data())
+ ->values();
+
+ expect($sent->all())->toEqual([
+ [
+ 'keys' => [
+ $missingKeyReport1->key->id() => $missingKeyReport1->key,
+ ],
+ ],
+ [
+ 'keys' => [
+ $missingKeyReport2->key->id() => $missingKeyReport2->key,
+ ],
+ ],
+ [
+ 'keys' => [
+ $missingKeyReport3->key->id() => $missingKeyReport3->key,
+ $missingKeyReport5->key->id() => $missingKeyReport5->key,
+ ],
+ ],
+ [
+ 'keys' => [
+ $missingKeyReport4->key->id() => $missingKeyReport4->key,
+ ],
+ ],
+ ]);
+});
+
+it('sents the reports to the correct project & branch', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/reports/missing-translation-keys' => Http::response(),
+ ]);
+
+ $projects = Transl::config()->projects();
+ $branch1 = Branch::asCurrent('yolo');
+ $branch2 = Branch::asFallback('yo');
+
+ $missingKeys = (new MissingTranslationKeys())
+ ->add('auth.password', [], 'en', true, $projects->first(), $branch1)
+ ->add('auth.password', [], 'en', true, $projects->first(), $branch1)
+ ->add('auth.password', [], 'en', true, $projects->last(), $branch1)
+ ->add('auth.password', [], 'en', true, $projects->first(), $branch2)
+ ->add('auth.password', [], 'en', true, $projects->last(), $branch2)
+ ->add('auth.password', [], 'ht', true, $projects->first(), $branch2);
+
+ (new SendMissingTranslationKeyReportAction())->execute($missingKeys->queued());
+
+ /** @var Collection $requests */
+ $requests = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->values();
+
+ $projectAuthKeys = $requests
+ ->map(static fn (Request $request): array => Arr::only($request->headers(), 'Authorization'));
+ $branchNames = $requests
+ ->map(static fn (Request $request): array => Arr::only($request->headers(), 'X-Transl-Branch-Name'));
+
+ expect($projectAuthKeys->all())->toEqual([
+ [
+ 'Authorization' => [
+ "Bearer {$projects->first()->auth_key}",
+ ],
+ ],
+ [
+ 'Authorization' => [
+ "Bearer {$projects->last()->auth_key}",
+ ],
+ ],
+ [
+ 'Authorization' => [
+ "Bearer {$projects->first()->auth_key}",
+ ],
+ ],
+ [
+ 'Authorization' => [
+ "Bearer {$projects->last()->auth_key}",
+ ],
+ ],
+ ]);
+ expect($branchNames->all())->toEqual([
+ [
+ 'X-Transl-Branch-Name' => [
+ $branch1->name,
+ ],
+ ],
+ [
+ 'X-Transl-Branch-Name' => [
+ $branch1->name,
+ ],
+ ],
+ [
+ 'X-Transl-Branch-Name' => [
+ $branch2->name,
+ ],
+ ],
+ [
+ 'X-Transl-Branch-Name' => [
+ $branch2->name,
+ ],
+ ],
+ ]);
+});
+
+it('sets the necessary HTTP headers & custom Transl HTTP headers', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/reports/missing-translation-keys' => Http::response(),
+ ]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKeys = (new MissingTranslationKeys())
+ ->add('auth.password', [], 'ht', true, $project, $branch)
+ ->add('auth.password', [], 'jp', true, $project, $branch)
+ ->add('auth.password', [], 'it', true, $project, $branch);
+
+ (new SendMissingTranslationKeyReportAction())->execute($missingKeys->queued());
+
+ $versions = Transl::versions();
+ $packageName = Transl::PACKAGE_NAME;
+
+ $userAgentPackageName = str_replace('/', '___', $packageName);
+ $userAgent = "{$userAgentPackageName}/{$versions->package} laravel/{$versions->laravel} php/{$versions->php}";
+
+ Http::assertSent(static function (Request $request) use ($project, $branch, $versions, $packageName, $userAgent): bool {
+ return $request->hasHeaders([
+ 'User-Agent' => $userAgent,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+
+ 'Authorization' => "Bearer {$project->auth_key}",
+
+ 'X-Transl-Branch-Name' => $branch->name,
+ 'X-Transl-Branch-Provenance' => $branch->provenance(),
+
+ 'X-Transl-Package-Name' => $packageName,
+ 'X-Transl-Package-Version' => $versions->package,
+ 'X-Transl-Framework-Name' => 'Laravel',
+ 'X-Transl-Framework-Version' => $versions->laravel,
+ 'X-Transl-Language-Name' => 'PHP',
+ 'X-Transl-Language-Version' => $versions->php,
+ ]);
+ });
+});
diff --git a/tests/src/Commands/TranslAnalyseCommandTest.php b/tests/src/Commands/TranslAnalyseCommandTest.php
new file mode 100644
index 0000000..ff15025
--- /dev/null
+++ b/tests/src/Commands/TranslAnalyseCommandTest.php
@@ -0,0 +1,34 @@
+withoutMockingConsoleOutput()->artisan('transl:analyse --branch=yolo'),
+ )->toEqual(TranslAnalyseCommand::SUCCESS);
+ // expect(Artisan::output())->toMatchConsoleOutput();
+});
+
+it('works (verbose)', function (): void {
+ expect(
+ $this->withoutMockingConsoleOutput()->artisan('transl:analyse --branch=yolo -v'),
+ )->toEqual(TranslAnalyseCommand::SUCCESS);
+ // expect(Artisan::output())->toMatchConsoleOutput();
+});
+
+it('works (very verbose)', function (): void {
+ expect(
+ $this->withoutMockingConsoleOutput()->artisan('transl:analyse --branch=yolo -vv'),
+ )->toEqual(TranslAnalyseCommand::SUCCESS);
+ // expect(Artisan::output())->toMatchConsoleOutput();
+});
+
+it('works (debug)', function (): void {
+ expect(
+ $this->withoutMockingConsoleOutput()->artisan('transl:analyse --branch=yolo -vvv'),
+ )->toEqual(TranslAnalyseCommand::SUCCESS);
+ // expect(Artisan::output())->toMatchConsoleOutput();
+});
diff --git a/tests/src/Commands/TranslInitCommandTest.php b/tests/src/Commands/TranslInitCommandTest.php
new file mode 100644
index 0000000..bac299a
--- /dev/null
+++ b/tests/src/Commands/TranslInitCommandTest.php
@@ -0,0 +1,112 @@
+setBasePath($this->getTestSupportDirectory('.to-delete/TranslInitCommandTest'));
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ app(Filesystem::class)->copyDirectory($this->getTestSupportDirectory('lang'), lang_path());
+
+ $this->getTranslationSets = static fn (ProjectConfiguration $project, Branch $branch): Collection => (
+ collect(app($project->drivers->toBase()->keys()->first())->getTranslationSets($project, $branch))
+ );
+});
+
+afterEach(function (): void {
+ app(Filesystem::class)->deleteDirectory($this->getTestSupportDirectory('.to-delete'));
+
+ app()->setBasePath($this->getTestSupportDirectory());
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+});
+
+it('works', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/init/start' => Http::response(),
+ 'https://api.transl.me/v0/commands/init/end' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $project = Transl::config()->defaults()->project;
+ $project = Transl::config()->projects()->whereAuthKeyOrName($project)->first();
+ $branch = Branch::asProvided('yolo');
+
+ $trackedDirectory = "{$project->options->transl_directory}/{$project->auth_key}/{$branch->name}/tracked";
+
+ expect(file_exists($trackedDirectory))->toEqual(false);
+
+ expect(Artisan::call(TranslInitCommand::class, ['--branch' => $branch->name]))->toEqual(TranslInitCommand::SUCCESS);
+ // expect(Artisan::output())->toMatchConsoleOutput();
+
+ expect(file_exists($trackedDirectory))->toEqual(true);
+
+ $translationSets = $this->getTranslationSets->__invoke($project, $branch);
+ $sentTranslationSets = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => $data['chunk']['translation_sets'])
+ ->map(static fn (array $set): TranslationSet => TranslationSet::from($set));
+
+ expect(
+ $translationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ )->toEqual(
+ $sentTranslationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ );
+
+ foreach ($translationSets as $set) {
+ expect(file_exists("{$trackedDirectory}/{$set->trackingKey()}.json"))->toEqual(true);
+ }
+});
+
+it('uses `InitCommandAction` to initialize a project on Transl', function (): void {
+ app()->singleton(InitCommandAction::class, function (): InitCommandAction {
+ return new class () extends InitCommandAction {
+ public readonly bool $used;
+
+ public function execute(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?PushBatch $batch = null,
+ array $meta = [],
+ ): void {
+ $this->used = true;
+ }
+ };
+ });
+
+ Artisan::call(TranslInitCommand::class);
+
+ expect(app(InitCommandAction::class)->used)->toEqual(true);
+});
diff --git a/tests/src/Commands/TranslPullCommandTest.php b/tests/src/Commands/TranslPullCommandTest.php
new file mode 100644
index 0000000..44f5bc2
--- /dev/null
+++ b/tests/src/Commands/TranslPullCommandTest.php
@@ -0,0 +1,190 @@
+setBasePath($this->getTestSupportDirectory('.to-delete/TranslPullCommandTest'));
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ app(Filesystem::class)->copyDirectory($this->getTestSupportDirectory('lang'), lang_path());
+
+ app()->bind(TranslationSet::class, function (Application $app, array $params): TranslationSet {
+ return new TranslationSet(
+ ...Arr::except($params, ['meta']),
+ meta: [
+ 'translation_file' => [
+ 'full_path' => $this->helpers()
+ ->translationSet()
+ ->determineTranslationFileFullPath(...Arr::except($params, ['lines', 'meta'])),
+ ],
+ ],
+ );
+ });
+
+ File::swap(new class () extends Filesystem {
+ public function updateTranslationSet(TranslationSet $set, array|string $search, array|string $replace)
+ {
+ $path = $set->meta['translation_file']['full_path'];
+
+ $contents = $this->get($path);
+ $contents = str_replace($search, $replace, $contents);
+
+ return $this->put($path, $contents);
+ }
+ });
+
+ $this->defaultResponses = json_decode(
+ file_get_contents($this->getFixtureDirectory('pull_stub_responses.json')),
+ true,
+ );
+
+ $this->pulledTranslationSets = static fn (): Collection => (
+ collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Response)
+ ->map(static fn (Response $response): array => $response->json('data'))
+ ->flatten(1)
+ ->map(static fn (array $set): TranslationSet => TranslationSet::new(
+ locale: $set['attributes']['locale'],
+ group: $set['attributes']['group'],
+ namespace: $set['attributes']['namespace'],
+ lines: TranslationLineCollection::make(
+ array_map(static function (array $line): TranslationLine {
+ return TranslationLine::make(
+ key: $line['attributes']['key'],
+ value: $line['attributes']['value'],
+ meta: $line['meta'],
+ );
+ }, $set['relations']['lines']['data']),
+ ),
+ meta: $set['meta'],
+ ))
+ );
+
+ $this->getTranslationSets = static fn (ProjectConfiguration $project, Branch $branch): Collection => (
+ collect(app($project->drivers->toBase()->keys()->first())->getTranslationSets($project, $branch))
+ );
+
+ $this->trackTranslationSet = static function (ProjectConfiguration $project, Branch $branch, TranslationSet $set): void {
+ $directory = "{$project->options->transl_directory}/{$project->auth_key}/{$branch->name}/tracked";
+ $fullPath = "{$directory}/{$set->trackingKey()}.json";
+
+ app(Filesystem::class)->ensureDirectoryExists(dirname($fullPath));
+
+ app(Filesystem::class)->put("{$directory}/{$set->trackingKey()}.json", json_encode($set->toArray()));
+ };
+});
+
+afterEach(function (): void {
+ app(Filesystem::class)->deleteDirectory($this->getTestSupportDirectory('.to-delete'));
+
+ app()->setBasePath($this->getTestSupportDirectory());
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+});
+
+it('works', function (): void {
+ $this->defaultResponses = [
+ 'https://api.transl.me/v0/commands/yolo/pull' => [
+ ...$this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull'],
+ 'pagination' => [
+ ...$this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['pagination'],
+ 'attributes' => [
+ ...$this->defaultResponses['https://api.transl.me/v0/commands/yolo/pull']['pagination']['attributes'],
+ 'has_more_pages' => false,
+ ],
+ ],
+ ],
+ ];
+
+ Http::fake($this->defaultResponses);
+
+ $project = Transl::config()->defaults()->project;
+ $project = Transl::config()->projects()->whereAuthKeyOrName($project)->first();
+ $branch = Branch::asProvided('yolo');
+ $conflictResolution = BranchingConflictResolutionEnum::MERGE_AND_IGNORE;
+
+ $currentSets = $this->getTranslationSets->__invoke($project, $branch);
+
+ foreach ($currentSets as $set) {
+ $this->trackTranslationSet->__invoke($project, $branch, $set);
+ }
+
+ $conflictingSet = $currentSets->first(static function (TranslationSet $set): bool {
+ return (
+ $set->locale === 'en'
+ && $set->group === 'auth'
+ && $set->namespace === null
+ );
+ });
+ $conflictingLine = $conflictingSet->lines->firstWhere('key', 'failed_bis');
+
+ app(Filesystem::class)->updateTranslationSet(
+ $conflictingSet,
+ $conflictingLine->value,
+ "[UPDATE ON CURRENT] {$conflictingLine->value}",
+ );
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect(
+ Artisan::call(TranslPullCommand::class, [
+ '--branch' => $branch->name,
+ '--conflicts' => $conflictResolution->value,
+ ]),
+ )->toEqual(TranslPullCommand::SUCCESS);
+ // expect(Artisan::output())->toMatchConsoleOutput();
+
+ expect(
+ str_contains(file_get_contents($conflictingSet->meta['translation_file']['full_path']), '[UPDATE ON CURRENT]'),
+ )->toEqual(true);
+
+ expect($this->pulledTranslationSets->__invoke()->isEmpty())->toEqual(false);
+});
+
+it('uses `PullCommandAction` to pull translation sets from Transl', function (): void {
+ app()->singleton(PullCommandAction::class, function (): PullCommandAction {
+ return new class () extends PullCommandAction {
+ public readonly bool $used;
+
+ public function execute(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?BranchingConflictResolutionEnum $conflictResolution = null,
+ ): void {
+ $this->used = true;
+ }
+ };
+ });
+
+ Artisan::call(TranslPullCommand::class);
+
+ expect(app(PullCommandAction::class)->used)->toEqual(true);
+});
diff --git a/tests/src/Commands/TranslPushCommandTest.php b/tests/src/Commands/TranslPushCommandTest.php
new file mode 100644
index 0000000..765525b
--- /dev/null
+++ b/tests/src/Commands/TranslPushCommandTest.php
@@ -0,0 +1,110 @@
+setBasePath($this->getTestSupportDirectory('.to-delete/TranslPushCommandTest'));
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+
+ app(Filesystem::class)->copyDirectory($this->getTestSupportDirectory('lang'), lang_path());
+
+ $this->getTranslationSets = static fn (ProjectConfiguration $project, Branch $branch): Collection => (
+ collect(app($project->drivers->toBase()->keys()->first())->getTranslationSets($project, $branch))
+ );
+});
+
+afterEach(function (): void {
+ app(Filesystem::class)->deleteDirectory($this->getTestSupportDirectory('.to-delete'));
+
+ app()->setBasePath($this->getTestSupportDirectory());
+
+ config()->set('transl.defaults.project_options.transl_directory', storage_path('app/.transl'));
+
+ Configuration::refreshInstance(config('transl'));
+});
+
+it('works', function (): void {
+ Http::fake([
+ 'https://api.transl.me/v0/commands/yolo/push' => Http::response(),
+ 'https://api.transl.me/v0/commands/yolo/push/end' => Http::response(),
+ ]);
+
+ $project = Transl::config()->defaults()->project;
+ $project = Transl::config()->projects()->whereAuthKeyOrName($project)->first();
+ $branch = Branch::asProvided('yolo');
+
+ $trackedDirectory = "{$project->options->transl_directory}/{$project->auth_key}/{$branch->name}/tracked";
+
+ expect(file_exists($trackedDirectory))->toEqual(false);
+
+ expect(Artisan::call(TranslPushCommand::class, ['--branch' => $branch->name]))->toEqual(TranslPushCommand::SUCCESS);
+ // expect(Artisan::output())->toMatchConsoleOutput();
+
+ expect(file_exists($trackedDirectory))->toEqual(true);
+
+ $translationSets = $this->getTranslationSets->__invoke($project, $branch);
+ $sentTranslationSets = collect(Http::recorded())
+ ->flatten()
+ ->filter(static fn (Request|Response $item): bool => $item instanceof Request)
+ ->filter(static fn (Request $request): bool => str_ends_with($request->url(), '/push'))
+ ->map(static fn (Request $request): array => $request->data())
+ ->flatMap(static fn (array $data): array => $data['chunk']['translation_sets'])
+ ->map(static fn (array $set): TranslationSet => TranslationSet::from($set));
+
+ expect(
+ $translationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ )->toEqual(
+ $sentTranslationSets
+ ->map(static fn (TranslationSet $set): string => $set->trackingKey())
+ ->sort()
+ ->values()
+ ->all(),
+ );
+
+ foreach ($translationSets as $set) {
+ expect(file_exists("{$trackedDirectory}/{$set->trackingKey()}.json"))->toEqual(true);
+ }
+});
+
+it('uses `PushCommandAction` to push translation sets to Transl', function (): void {
+ app()->singleton(PushCommandAction::class, function (): PushCommandAction {
+ return new class () extends PushCommandAction {
+ public readonly bool $used;
+
+ public function execute(
+ ProjectConfiguration $project,
+ Branch $branch,
+ ?PushBatch $batch = null,
+ array $meta = [],
+ ): void {
+ $this->used = true;
+ }
+ };
+ });
+
+ Artisan::call(TranslPushCommand::class);
+
+ expect(app(PushCommandAction::class)->used)->toEqual(true);
+});
diff --git a/tests/src/Commands/TranslSynchCommandTest.php b/tests/src/Commands/TranslSynchCommandTest.php
new file mode 100644
index 0000000..775e8f6
--- /dev/null
+++ b/tests/src/Commands/TranslSynchCommandTest.php
@@ -0,0 +1,62 @@
+singleton(TranslPullCommand::class, function (): TranslPullCommand {
+ return new class () extends TranslPullCommand {
+ public readonly bool $used;
+
+ public function handle(): int
+ {
+ $this->used = true;
+
+ return TranslPullCommand::SUCCESS;
+ }
+ };
+ });
+ app()->singleton(TranslPushCommand::class, static function (): TranslPushCommand {
+ return new class () extends TranslPushCommand {
+ public function handle(): int
+ {
+ return TranslPushCommand::SUCCESS;
+ }
+ };
+ });
+
+ Artisan::call(TranslSynchCommand::class);
+
+ expect(app(TranslPullCommand::class)->used)->toEqual(true);
+});
+
+it('uses `TranslPushCommand` to push translation sets to Transl', function (): void {
+ app()->singleton(TranslPullCommand::class, static function (): TranslPullCommand {
+ return new class () extends TranslPullCommand {
+ public function handle(): int
+ {
+ return TranslPullCommand::SUCCESS;
+ }
+ };
+ });
+ app()->singleton(TranslPushCommand::class, function (): TranslPushCommand {
+ return new class () extends TranslPushCommand {
+ public readonly bool $used;
+
+ public function handle(): int
+ {
+ $this->used = true;
+
+ return TranslPushCommand::SUCCESS;
+ }
+ };
+ });
+
+ Artisan::call(TranslSynchCommand::class);
+
+ expect(app(TranslPushCommand::class)->used)->toEqual(true);
+});
diff --git a/tests/src/Drivers/LocalFilesDriverTest.php b/tests/src/Drivers/LocalFilesDriverTest.php
new file mode 100644
index 0000000..5d7645a
--- /dev/null
+++ b/tests/src/Drivers/LocalFilesDriverTest.php
@@ -0,0 +1,233 @@
+toEqual(true);
+});
+
+test('the "getTranslationContents" method uses the `GetTranslationContentsFromLocalFilesAction` action', function (): void {
+ app()->bind(
+ GetTranslationContentsFromLocalFilesAction::class,
+ static fn () => new class () extends GetTranslationContentsFromLocalFilesAction {
+ public function execute(string $locale, ?string $group, ?string $namespace): array
+ {
+ return compact('locale', 'group', 'namespace');
+ }
+ },
+ );
+
+ $result = (new LocalFilesDriver())->getTranslationContents(
+ project: Transl::config()->projects()->first(),
+ branch: Branch::asCurrent('test'),
+ locale: 'en',
+ group: 'auth',
+ namespace: 'example',
+ );
+
+ expect($result)->toEqual([
+ 'locale' => 'en',
+ 'group' => 'auth',
+ 'namespace' => 'example',
+ ]);
+});
+
+test('the "translationContentsToTranslationSet" method uses the `TranslationContentsToTranslationSetAction` action', function (): void {
+ app()->bind(
+ TranslationContentsToTranslationSetAction::class,
+ static fn () => new class () extends TranslationContentsToTranslationSetAction {
+ public function execute(array $contents, string $locale, ?string $group, ?string $namespace, ?array $meta): TranslationSet
+ {
+ return TranslationSet::new(
+ locale: "child-{$locale}",
+ group: "child-{$group}",
+ namespace: "child-{$namespace}",
+ lines: TranslationLineCollection::fromRawTranslationLines($contents),
+ meta: $meta,
+ );
+ }
+ },
+ );
+
+ $result = (new LocalFilesDriver())->translationContentsToTranslationSet(
+ project: Transl::config()->projects()->first(),
+ branch: Branch::asCurrent('test'),
+ contents: [],
+ locale: 'en',
+ group: 'auth',
+ namespace: 'example',
+ meta: null,
+ );
+
+ expect($result->toArray())->toEqual(
+ TranslationSet::new(
+ locale: 'child-en',
+ group: 'child-auth',
+ namespace: 'child-example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ )->toArray(),
+ );
+});
+
+test('the "getTranslationSets" method uses the `GetTranslationSetsFromLocalFilesAction` action', function (): void {
+ app()->bind(
+ GetTranslationSetsFromLocalFilesAction::class,
+ static fn () => new class () extends GetTranslationSetsFromLocalFilesAction {
+ public function execute(): iterable
+ {
+ return ['stuff'];
+ }
+ },
+ );
+
+ $result = (new LocalFilesDriver())->getTranslationSets(
+ project: Transl::config()->projects()->first(),
+ branch: Branch::asCurrent('test'),
+ );
+
+ expect($result)->toEqual(['stuff']);
+});
+
+test('the "saveTranslationSet" method uses the `SaveTranslationSetToLocalFilesAction` action', function (): void {
+ $instance = new class () extends SaveTranslationSetToLocalFilesAction {
+ public bool $executed = false;
+
+ public function execute(): void
+ {
+ $this->executed = true;
+ }
+ };
+
+ app()->bind(
+ SaveTranslationSetToLocalFilesAction::class,
+ static fn () => $instance,
+ );
+
+ (new LocalFilesDriver())->saveTranslationSet(
+ project: Transl::config()->projects()->first(),
+ branch: Branch::asCurrent('test'),
+ set: TranslationSet::new(
+ locale: 'en',
+ group: 'auth',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ ),
+ );
+
+ expect($instance->executed)->toEqual(true);
+});
+
+test('the "getTrackedTranslationSet" method uses the `GetTrackedTranslationSetFromLocalFilesAction` action', function (): void {
+ app()->bind(
+ GetTrackedTranslationSetFromLocalFilesAction::class,
+ static fn () => new class () extends GetTrackedTranslationSetFromLocalFilesAction {
+ public function execute(TranslationSet $set): ?TranslationSet
+ {
+ return TranslationSet::new(
+ locale: "_child-{$set->locale}",
+ group: "_child-{$set->group}",
+ namespace: "_child-{$set->namespace}",
+ lines: TranslationLineCollection::make(),
+ meta: $set->meta,
+ );
+ }
+ },
+ );
+
+ $set = TranslationSet::new(
+ locale: 'en',
+ group: 'auth',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ );
+
+ $result = (new LocalFilesDriver())->getTrackedTranslationSet(
+ project: Transl::config()->projects()->first(),
+ branch: Branch::asCurrent('test'),
+ set: $set,
+ );
+
+ expect($result->toArray())->toEqual(
+ TranslationSet::new(
+ locale: "_child-{$set->locale}",
+ group: "_child-{$set->group}",
+ namespace: "_child-{$set->namespace}",
+ lines: TranslationLineCollection::make(),
+ meta: $set->meta,
+ )->toArray(),
+ );
+});
+
+test('the "saveTrackedTranslationSet" method uses the `SaveTrackedTranslationSetToLocalFilesAction` action', function (): void {
+ $instance = new class () extends SaveTrackedTranslationSetToLocalFilesAction {
+ public bool $executed = false;
+
+ public function execute(TranslationSet $set): void
+ {
+ $this->executed = true;
+ }
+ };
+
+ app()->bind(
+ SaveTrackedTranslationSetToLocalFilesAction::class,
+ static fn () => $instance,
+ );
+
+ (new LocalFilesDriver())->saveTrackedTranslationSet(
+ project: Transl::config()->projects()->first(),
+ branch: Branch::asCurrent('test'),
+ set: TranslationSet::new(
+ locale: 'en',
+ group: 'auth',
+ namespace: 'example',
+ lines: TranslationLineCollection::make(),
+ meta: null,
+ ),
+ );
+
+ expect($instance->executed)->toEqual(true);
+});
+
+it('ensures only supported translation loaders are used', function (): void {
+ app()->bind('translation.loader', static fn () => new class () implements Loader {
+ public function load($locale, $group, $namespace = null)
+ {
+ return [];
+ }
+
+ public function addNamespace($namespace, $hint): void
+ {
+ //
+ }
+
+ public function namespaces()
+ {
+ return [];
+ }
+
+ public function addJsonPath($path): void
+ {
+ //
+ }
+ });
+
+ expect(static fn () => (new LocalFilesDriver())->translationLoader())->toThrow(UnsupportedTranslationLoader::class);
+});
diff --git a/tests/src/Support/Analysis/ProjectAnalysisTest.php b/tests/src/Support/Analysis/ProjectAnalysisTest.php
new file mode 100644
index 0000000..cea975a
--- /dev/null
+++ b/tests/src/Support/Analysis/ProjectAnalysisTest.php
@@ -0,0 +1,16 @@
+projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $sets = app($project->drivers->toBase()->keys()->first())->getTranslationSets($project, $branch);
+
+ expect(ProjectAnalysis::fromTranslationSets($sets))->toMatchSnapshot();
+});
diff --git a/tests/src/Support/LocaleFilesystem/FilePathTest.php b/tests/src/Support/LocaleFilesystem/FilePathTest.php
new file mode 100644
index 0000000..f1add1a
--- /dev/null
+++ b/tests/src/Support/LocaleFilesystem/FilePathTest.php
@@ -0,0 +1,162 @@
+ str_replace(['\\', '/'], $separator, $path);
+
+it('can be newed up', function (): void {
+ expect(FilePath::new(__DIR__) instanceof FilePath)->toEqual(true);
+});
+
+it('can wrap up a given value', function (): void {
+ expect(FilePath::wrap(__DIR__)->fullPath())->toEqual(__DIR__);
+ expect(FilePath::wrap(FilePath::new(__DIR__))->fullPath())->toEqual(__DIR__);
+});
+
+it('can retrieve the root', function (): void {
+ expect(FilePath::new(__DIR__)->root())->toEqual(__DIR__);
+});
+
+it('can retrieve the relative path', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo')->relativePath())->toEqual('yolo');
+});
+
+it('can retrieve the directory separator', function (): void {
+ expect(FilePath::new('', '', '|')->directorySeparator())->toEqual('|');
+});
+
+it("doesn't trim the root's leading slash", function (): void {
+ expect(FilePath::new('/' . __DIR__)->root())->toEqual((windows_os() ? DIRECTORY_SEPARATOR : '') . __DIR__);
+});
+
+it("doesn't trim the root's leading backslash", function (): void {
+ expect(FilePath::new('\\' . __DIR__)->root())->toEqual((windows_os() ? DIRECTORY_SEPARATOR : '') . __DIR__);
+});
+
+it("trims the root's trailing slash", function (): void {
+ expect(FilePath::new(__DIR__ . '/')->root())->toEqual(__DIR__);
+});
+
+it("trims the root's trailing backslash", function (): void {
+ expect(FilePath::new(__DIR__ . '\\')->root())->toEqual(__DIR__);
+});
+
+it("trims the relative path's leading & trailing slashes", function (): void {
+ expect(FilePath::new(__DIR__, '/yolo/')->relativePath())->toEqual('yolo');
+});
+
+it("trims the relative path's leading & trailing backslashes", function (): void {
+ expect(FilePath::new(__DIR__, '\\yolo\\')->relativePath())->toEqual('yolo');
+});
+
+it("the root's path directory separator get standardized", function (): void {
+ expect(FilePath::new('/root/parent\\child/grand_child\\sub', '', '|')->root())->toEqual('|root|parent|child|grand_child|sub');
+});
+
+it("the relative path's path directory separator get standardized", function (): void {
+ expect(FilePath::new('', '/root/parent\\child/grand_child\\sub', '|')->relativePath())->toEqual('root|parent|child|grand_child|sub');
+});
+
+it("the root's path directory separator get standardized again on separator update", function (): void {
+ expect(
+ FilePath::new('/root/parent\\child/grand_child\\sub', '')->withDirectorySeparator('|')->root(),
+ )->toEqual('|root|parent|child|grand_child|sub');
+});
+
+it("the relative path's path directory separator get standardized again on separator update", function (): void {
+ expect(
+ FilePath::new('', '/root/parent\\child/grand_child\\sub')->withDirectorySeparator('|')->relativePath(),
+ )->toEqual('root|parent|child|grand_child|sub');
+});
+
+it('can retrieve the full path', function () use ($_): void {
+ expect(FilePath::new(__DIR__, 'yolo')->fullPath())->toEqual(__DIR__ . DIRECTORY_SEPARATOR . 'yolo');
+ expect(FilePath::new(__DIR__, 'yolo', '/')->fullPath())->toEqual($_(__DIR__ . '/yolo', '/'));
+ expect(FilePath::new(__DIR__, 'yolo', '\\')->fullPath())->toEqual($_(__DIR__ . '/yolo', '\\'));
+ expect(FilePath::new(__DIR__, 'yolo', '|')->fullPath())->toEqual($_(__DIR__ . '/yolo', '|'));
+});
+
+it('can retrieve the directory name', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo.php')->directoryName())->toEqual('LocaleFilesystem');
+});
+
+it('can retrieve the file name', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo.php')->fileName())->toEqual('yolo.php');
+});
+
+it('can retrieve the file name without extension', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo.php')->fileNameWithoutExtension())->toEqual('yolo');
+});
+
+it('can retrieve the file extension', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo.php')->extension())->toEqual('php');
+});
+
+it('can append a given path', function () use ($_): void {
+ expect(
+ FilePath::new(__DIR__, 'yolo', '|')->append('\\yo\\lo\\file.php')->fullPath(),
+ )->toEqual($_(__DIR__ . DIRECTORY_SEPARATOR . 'yolo/yo/lo/file.php', '|'));
+});
+
+it('can retrieve the relative path from a given string root', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo')->relativeFrom(__DIR__))->toEqual('yolo');
+});
+
+it('can retrieve the relative path from a given "FilePath" root', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo')->relativeFrom(FilePath::new(__DIR__)))->toEqual('yolo');
+});
+
+it('returns the fullpath when the given root is not a parent directory', function (): void {
+ expect(
+ FilePath::new(__DIR__, 'yolo')->relativeFrom(__DIR__ . '/nope'),
+ )->toEqual(__DIR__ . DIRECTORY_SEPARATOR . 'yolo');
+});
+
+it('returns an empty string when the given root is the same as the existing full path', function (): void {
+ expect(FilePath::new(__DIR__, 'yolo')->relativeFrom(__DIR__ . '/yolo'))->toEqual('');
+});
+
+it("can check it's existence", function (): void {
+ expect(FilePath::new(__DIR__, 'yolo.php')->exists())->toEqual(false);
+ expect(FilePath::new(__DIR__, 'FilePathTest.php')->exists())->toEqual(true);
+});
+
+it("can check if it's a directory", function (): void {
+ expect(FilePath::new(__DIR__, 'yolo.php')->isDirectory())->toEqual(false);
+ expect(FilePath::new(__DIR__, 'FilePathTest.php')->isDirectory())->toEqual(false);
+ expect(FilePath::new(__DIR__, '')->isDirectory())->toEqual(true);
+ expect(FilePath::new(dirname(__DIR__), 'LocaleFilesystem')->isDirectory())->toEqual(true);
+});
+
+it("can check if it's a file", function (): void {
+ expect(FilePath::new(__DIR__, 'yolo.php')->isFile())->toEqual(false);
+ expect(FilePath::new(__DIR__, 'FilePathTest.php')->isFile())->toEqual(true);
+ expect(FilePath::new(__DIR__, '')->isFile())->toEqual(false);
+ expect(FilePath::new(dirname(__DIR__), 'LocaleFilesystem')->isFile())->toEqual(false);
+ expect(FilePath::new(__FILE__)->isFile())->toEqual(true);
+});
+
+it("can check if it's nested within a given root path", function (): void {
+ expect(FilePath::new(__DIR__)->isNestedWithin(dirname(__DIR__)))->toEqual(true);
+ expect(FilePath::new(__DIR__)->isNestedWithin(dirname(__DIR__, 2)))->toEqual(true);
+ expect(FilePath::new(__DIR__)->isNestedWithin(dirname(__DIR__, 3)))->toEqual(true);
+ expect(FilePath::new(__DIR__, 'yolo.php')->isNestedWithin(__DIR__))->toEqual(true);
+
+ expect(FilePath::new(__DIR__)->isNestedWithin(__DIR__))->toEqual(false);
+ expect(FilePath::new(dirname(__DIR__))->isNestedWithin(__DIR__))->toEqual(false);
+ expect(FilePath::new(dirname(__DIR__, 2))->isNestedWithin(__DIR__))->toEqual(false);
+ expect(FilePath::new(dirname(__DIR__, 3))->isNestedWithin(__DIR__))->toEqual(false);
+});
+
+it('implements the "Arrayable" contract', function (): void {
+ $path = FilePath::new(__DIR__, 'yolo.php');
+
+ expect($path->toArray())->toEqual([
+ 'root' => $path->root(),
+ 'relative_path' => $path->relativePath(),
+ 'directory_separator' => $path->directorySeparator(),
+ 'full_path' => $path->fullPath(),
+ ]);
+});
diff --git a/tests/src/Support/LocaleFilesystem/LangFilePathTest.php b/tests/src/Support/LocaleFilesystem/LangFilePathTest.php
new file mode 100644
index 0000000..b0895eb
--- /dev/null
+++ b/tests/src/Support/LocaleFilesystem/LangFilePathTest.php
@@ -0,0 +1,356 @@
+toEqual(true);
+ });
+
+ it('can retrieve the relative vendor path from an unnamed vendor', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php', '/')->relativeFromPackage($languageDirectory))->toEqual('en/auth.php');
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php', '/')->relativeFromPackage($new($languageDirectory)))->toEqual('en/auth.php');
+ });
+
+ it('can retrieve the relative vendor path from a given vendor name', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php', '/')->relativeFromPackage($languageDirectory, 'yolo'))->toEqual('en/auth.php');
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php', '/')->relativeFromPackage($new($languageDirectory), 'yolo'))->toEqual('en/auth.php');
+ });
+
+ it('returns the full path when trying to retrieve the relative vendor path from an invalid vendor name', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+ $langFilePath = $new($languageDirectory, '/vendor/yolo/en/auth.php', '/');
+
+ expect($langFilePath->relativeFromPackage($languageDirectory, 'nope'))->toEqual($langFilePath->fullPath());
+ expect($langFilePath->relativeFromPackage($new($languageDirectory), 'nope'))->toEqual($langFilePath->fullPath());
+ });
+
+ it("can determine if it's a JSON file or not", function () use ($new): void {
+ expect($new('auth.php')->isJson())->toEqual(false);
+ expect($new('auth.json')->isJson())->toEqual(true);
+ expect($new('auth.nope')->isJson())->toEqual(false);
+ });
+
+ it("can determine if it's a PHP file or not", function () use ($new): void {
+ expect($new('auth.php')->isPhp())->toEqual(true);
+ expect($new('auth.json')->isPhp())->toEqual(false);
+ expect($new('auth.nope')->isPhp())->toEqual(false);
+ });
+
+ it("can determine if it's a package file or not", function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo')->isPackage())->toEqual(true);
+ expect($new($languageDirectory, '/vendor/yolo/auth.php')->isPackage())->toEqual(true);
+
+ expect($new("{$languageDirectory}/sub_path/", '/vendor/yolo')->isPackage())->toEqual(true);
+ expect($new("{$languageDirectory}/sub_path/", '/vendor/yolo/auth.php')->isPackage())->toEqual(true);
+ expect($new("{$languageDirectory}/vendor/", '/vendor/yolo')->isPackage())->toEqual(true);
+ expect($new("{$languageDirectory}/vendor/", '/vendor/yolo/auth.php')->isPackage())->toEqual(true);
+
+ expect($new("{$languageDirectory}/vendor/", '/')->isPackage())->toEqual(false);
+ expect($new("{$languageDirectory}/vendor/", '/yolo')->isPackage())->toEqual(false);
+ expect($new("{$languageDirectory}/vendor/", '/yolo/auth.php')->isPackage())->toEqual(false);
+
+ expect($new($languageDirectory, '/vendor/')->isPackage())->toEqual(false);
+ expect($new("{$languageDirectory}/sub_path/", '/vendor/')->isPackage())->toEqual(false);
+ expect($new("{$languageDirectory}/vendor/", '/vendor/')->isPackage())->toEqual(false);
+ });
+
+ it("can determine if it's in a composer vendor directory or not", function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo')->inVendor())->toEqual(false);
+ expect($new($languageDirectory, '/vendor/yolo/auth.php')->inVendor())->toEqual(false);
+
+ expect($new("{$languageDirectory}/sub_path/", '/vendor/yolo')->inVendor())->toEqual(false);
+ expect($new("{$languageDirectory}/sub_path/", '/vendor/yolo/auth.php')->inVendor())->toEqual(false);
+ expect($new("{$languageDirectory}/vendor/", '/vendor/yolo')->inVendor())->toEqual(true);
+ expect($new("{$languageDirectory}/vendor/", '/vendor/yolo/auth.php')->inVendor())->toEqual(true);
+
+ expect($new("{$languageDirectory}/vendor/", '/')->inVendor())->toEqual(true);
+ expect($new("{$languageDirectory}/vendor/", '/yolo')->inVendor())->toEqual(true);
+ expect($new("{$languageDirectory}/vendor/", '/yolo/auth.php')->inVendor())->toEqual(true);
+
+ expect($new($languageDirectory, '/vendor/')->inVendor())->toEqual(false);
+ expect($new("{$languageDirectory}/sub_path/", '/vendor/')->inVendor())->toEqual(false);
+ expect($new("{$languageDirectory}/vendor/", '/vendor/')->inVendor())->toEqual(true);
+ });
+});
+
+describe('PHP lang files', function () use ($new): void {
+ /* Guess locale
+ ------------------------------------------------*/
+
+ it('can guess the locale from a PHP translation file given a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en/auth.php')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/unknown/auth.php')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/en/auth.php')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/unknown/auth.php')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ it('can guess the locale from a PHP translation file given a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en/pages/dashboard/nav.php')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/unknown/pages/dashboard/nav.php')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/en/pages/dashboard/nav.php')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/unknown/pages/dashboard/nav.php')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ it('can guess the locale from a PHP translation file given a vendor with a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/auth.php')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/auth.php')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ it('can guess the locale from a PHP translation file given a vendor with a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/pages/dashboard/nav.php')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/pages/dashboard/nav.php')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/vendor/yolo/en/pages/dashboard/nav.php')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/pages/dashboard/nav.php')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ /* Guess group
+ ------------------------------------------------*/
+
+ it('can guess the group from a PHP translation file given a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en/auth.php')->guessGroup($languageDirectory))->toEqual('auth');
+ expect($new($languageDirectory, '/unknown/auth.php')->guessGroup($languageDirectory))->toEqual('auth');
+
+ expect($new($languageDirectory, '/en/auth.php')->guessGroup($new($languageDirectory)))->toEqual('auth');
+ expect($new($languageDirectory, '/unknown/auth.php')->guessGroup($new($languageDirectory)))->toEqual('auth');
+ });
+
+ it('can guess the group from a PHP translation file given a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en/pages/dashboard/nav.php')->guessGroup($languageDirectory))->toEqual('pages/dashboard/nav');
+ expect($new($languageDirectory, '/unknown/pages/dashboard/nav.php')->guessGroup($languageDirectory))->toEqual('pages/dashboard/nav');
+
+ expect($new($languageDirectory, '/en/pages/dashboard/nav.php')->guessGroup($new($languageDirectory)))->toEqual('pages/dashboard/nav');
+ expect($new($languageDirectory, '/unknown/pages/dashboard/nav.php')->guessGroup($new($languageDirectory)))->toEqual('pages/dashboard/nav');
+ });
+
+ it('can guess the group from a PHP translation file given a vendor with a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php')->guessGroup($languageDirectory))->toEqual('auth');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/auth.php')->guessGroup($languageDirectory))->toEqual('auth');
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php')->guessGroup($new($languageDirectory)))->toEqual('auth');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/auth.php')->guessGroup($new($languageDirectory)))->toEqual('auth');
+ });
+
+ it('can guess the group from a PHP translation file given a vendor with a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/pages/dashboard/nav.php')->guessGroup($languageDirectory))->toEqual('pages/dashboard/nav');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/pages/dashboard/nav.php')->guessGroup($languageDirectory))->toEqual('pages/dashboard/nav');
+
+ expect($new($languageDirectory, '/vendor/yolo/en/pages/dashboard/nav.php')->guessGroup($new($languageDirectory)))->toEqual('pages/dashboard/nav');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/pages/dashboard/nav.php')->guessGroup($new($languageDirectory)))->toEqual('pages/dashboard/nav');
+ });
+
+ /* Guess namespace
+ ------------------------------------------------*/
+
+ it('can not guess the namespace from a PHP translation file given a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en/auth.php')->guessNamespace($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/unknown/auth.php')->guessNamespace($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/en/auth.php')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/unknown/auth.php')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can not guess the namespace from a PHP translation file given a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en/pages/dashboard/nav.php')->guessNamespace($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/unknown/pages/dashboard/nav.php')->guessNamespace($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/en/pages/dashboard/nav.php')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/unknown/pages/dashboard/nav.php')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can guess the namespace from a PHP translation file given a vendor with a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php')->guessNamespace($languageDirectory))->toEqual('yolo');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/auth.php')->guessNamespace($languageDirectory))->toEqual('yolo');
+
+ expect($new($languageDirectory, '/vendor/yolo/en/auth.php')->guessNamespace($new($languageDirectory)))->toEqual('yolo');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/auth.php')->guessNamespace($new($languageDirectory)))->toEqual('yolo');
+ });
+
+ it('can guess the namespace from a PHP translation file given a vendor with a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en/pages/dashboard/nav.php')->guessNamespace($languageDirectory))->toEqual('yolo');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/pages/dashboard/nav.php')->guessNamespace($languageDirectory))->toEqual('yolo');
+
+ expect($new($languageDirectory, '/vendor/yolo/en/pages/dashboard/nav.php')->guessNamespace($new($languageDirectory)))->toEqual('yolo');
+ expect($new($languageDirectory, '/vendor/yolo/unknown/pages/dashboard/nav.php')->guessNamespace($new($languageDirectory)))->toEqual('yolo');
+ });
+});
+
+describe('JSON lang files', function () use ($new): void {
+ /* Guess locale
+ ------------------------------------------------*/
+
+ it('can guess the locale from a JSON translation file given a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en.json')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/unknown.json')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/en.json')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/unknown.json')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ it('can guess the locale from a JSON translation file given a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/pages/dashboard/nav/en.json')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/pages/dashboard/nav/unknown.json')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/pages/dashboard/nav/en.json')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/pages/dashboard/nav/unknown.json')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ it('can guess the locale from a JSON translation file given a vendor with a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/en.json')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/unknown.json')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/vendor/yolo/en.json')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/unknown.json')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ it('can guess the locale from a JSON translation file given a vendor with a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/en.json')->guessLocale($languageDirectory))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/unknown.json')->guessLocale($languageDirectory))->toEqual('unknown');
+
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/en.json')->guessLocale($new($languageDirectory)))->toEqual('en');
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/unknown.json')->guessLocale($new($languageDirectory)))->toEqual('unknown');
+ });
+
+ /* Guess group
+ ------------------------------------------------*/
+
+ it('can not guess the group from a JSON translation file given a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en.json')->guessGroup($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/unknown.json')->guessGroup($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/en.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/unknown.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can not guess the group from a JSON translation file given a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/pages/dashboard/nav/en.json')->guessGroup($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/pages/dashboard/nav/unknown.json')->guessGroup($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/pages/dashboard/nav/en.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/pages/dashboard/nav/unknown.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can not guess the group from a JSON translation file given a vendor with a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/auth/en.json')->guessGroup($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/auth/unknown.json')->guessGroup($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/vendor/yolo/auth/en.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/auth/unknown.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can not guess the group from a JSON translation file given a vendor with a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/en.json')->guessGroup($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/unknown.json')->guessGroup($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/en.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/unknown.json')->guessGroup($new($languageDirectory)))->toEqual(null);
+ });
+
+ /* Guess namespace
+ ------------------------------------------------*/
+
+ it('can not guess the namespace from a JSON translation file given a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/en.json')->guessNamespace($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/unknown.json')->guessNamespace($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/en.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/unknown.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can not guess the namespace from a JSON translation file given a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/pages/dashboard/nav/en.json')->guessNamespace($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/pages/dashboard/nav/unknown.json')->guessNamespace($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/pages/dashboard/nav/en.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/pages/dashboard/nav/unknown.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can not guess the namespace from a JSON translation file given a vendor with a regular file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/auth/en.json')->guessNamespace($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/auth/unknown.json')->guessNamespace($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/vendor/yolo/auth/en.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/auth/unknown.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ });
+
+ it('can not guess the namespace from a JSON translation file given a vendor with a sub file path', function () use ($new): void {
+ $languageDirectory = '/project/lang';
+
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/en.json')->guessNamespace($languageDirectory))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/unknown.json')->guessNamespace($languageDirectory))->toEqual(null);
+
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/en.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ expect($new($languageDirectory, '/vendor/yolo/pages/dashboard/nav/unknown.json')->guessNamespace($new($languageDirectory)))->toEqual(null);
+ });
+});
diff --git a/tests/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeysTest.php b/tests/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeysTest.php
new file mode 100644
index 0000000..7a41f67
--- /dev/null
+++ b/tests/src/Support/Reports/MissingTranslationKeys/MissingTranslationKeysTest.php
@@ -0,0 +1,1181 @@
+ '__feature/new__',
+ ]);
+
+ Process::fake([
+ Git::getDefaultConfiguredBranchNameCommand() => '__default__',
+ ]);
+
+ $this->translConfig = config('transl');
+});
+
+afterEach(function (): void {
+ config()->set('transl', $this->translConfig);
+
+ Configuration::refreshInstance(config('transl'));
+});
+
+/* Successes
+------------------------------------------------*/
+
+describe('queueing with all params given', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can queue a missing translation key report', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->queue($missingKeyReport);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing with a given project "auth_key"', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project->auth_key, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project->auth_key, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing with a given project "name"', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project->name, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project->name, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing with a branch given as a string', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asProvided('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project, $branch->name);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asProvided('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project, $branch->name);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing with a project and branch given as a strings', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asProvided('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project->auth_key, $branch->name);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asProvided('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project->auth_key, $branch->name);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can queue a missing translation key report', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->queue($missingKeyReport);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing without a given project', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, null, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, null, $branch);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing without a given branch (project allows mirroring)', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent(Git::currentBranchName());
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent(Git::currentBranchName());
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing without a given branch (project disallows mirroring but provides default)', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.defaults.project_options.branching.mirror_current_branch', false);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asDefault($project->options->branching->default_branch_name);
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asDefault($project->options->branching->default_branch_name);
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing without a given branch (project disallows mirroring & does not provide default)', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.defaults.project_options.branching.mirror_current_branch', false);
+ config()->set('transl.defaults.project_options.branching.default_branch_name', null);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asDefault(Git::defaultConfiguredBranchName());
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asDefault(Git::defaultConfiguredBranchName());
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing without a given branch (uses fallback when necessary)', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.defaults.project_options.branching.mirror_current_branch', false);
+ config()->set('transl.defaults.project_options.branching.default_branch_name', null);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('can add a raw missing translation key', function (): void {
+ Process::fake([
+ Git::getDefaultConfiguredBranchNameCommand() => '',
+ ]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asFallback(Transl::FALLBACK_BRANCH_NAME);
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ Process::fake([
+ Git::getDefaultConfiguredBranchNameCommand() => '',
+ ]);
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asFallback(Transl::FALLBACK_BRANCH_NAME);
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, $project, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('queueing without a given project or branch', function (): void {
+ it('can add a raw missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent(Git::currentBranchName());
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, null, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can register a missing translation key', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent(Git::currentBranchName());
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, null, null);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+});
+
+describe('misc.', function (): void {
+ it("is registered on Laravel's container", function (): void {
+ expect(app()->bound(MissingTranslationKeys::class))->toEqual(true);
+ });
+
+ it("is registered on Laravel's container as a singleton", function (): void {
+ (new MissingTranslationKeys())->add('auth.password', [], 'en', true);
+ (new MissingTranslationKeys())->add('auth.password', [], 'es', true);
+ (new MissingTranslationKeys())->add('auth.password', [], 'fr', true);
+
+ $missingKeys = new MissingTranslationKeys();
+
+ expect(count($missingKeys->queued()))->toEqual(0);
+
+ $missingKeys = app(MissingTranslationKeys::class);
+
+ expect(count($missingKeys->queued()))->toEqual(0);
+
+ app(MissingTranslationKeys::class)->add('auth.password', [], 'en', true);
+ app(MissingTranslationKeys::class)->add('auth.password', [], 'es', true);
+ app(MissingTranslationKeys::class)->add('auth.password', [], 'fr', true);
+
+ $missingKeys = app(MissingTranslationKeys::class);
+
+ expect(count($missingKeys->queued()))->toEqual(3);
+
+ $missingKeys->flushQueue();
+
+ expect(app(MissingTranslationKeys::class)->queued())->toEqual([]);
+ });
+
+ it('can set the entire queue', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->setQueue([$missingKeyReport]);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('can flush the entire queue', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->setQueue([$missingKeyReport]);
+
+ $missingKeys->flushQueue();
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+
+ it('does not allows for duplicates when adding raw missing translation keys', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch1 = Branch::asCurrent('yolo');
+ $branch2 = Branch::asFallback('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport1 = MissingTranslationKeyReport::new($project, $branch1, $missingKey);
+ $missingKeyReport2 = MissingTranslationKeyReport::new($project, $branch2, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())
+ ->add('auth.password', [], 'en', true, $project, $branch1)
+ ->add('auth.password', [], 'en', true, $project, $branch1)
+ ->add('auth.password', [], 'en', true, $project, $branch2);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport1->id() => $missingKeyReport1,
+ $missingKeyReport2->id() => $missingKeyReport2,
+ ]);
+ });
+
+ it('does not allows for duplicates when registering missing translation keys', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch1 = Branch::asCurrent('yolo');
+ $branch2 = Branch::asFallback('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport1 = MissingTranslationKeyReport::new($project, $branch1, $missingKey);
+ $missingKeyReport2 = MissingTranslationKeyReport::new($project, $branch2, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())
+ ->register($missingKey, $project, $branch1)
+ ->register($missingKey, $project, $branch1)
+ ->register($missingKey, $project, $branch2);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport1->id() => $missingKeyReport1,
+ $missingKeyReport2->id() => $missingKeyReport2,
+ ]);
+ });
+
+ it('does not allows for duplicates when queueing missing translation key reports', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch1 = Branch::asCurrent('yolo');
+ $branch2 = Branch::asFallback('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport1 = MissingTranslationKeyReport::new($project, $branch1, $missingKey);
+ $missingKeyReport1Bis = MissingTranslationKeyReport::new($project, $branch1, $missingKey);
+ $missingKeyReport2 = MissingTranslationKeyReport::new($project, $branch2, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())
+ ->queue($missingKeyReport1)
+ ->queue($missingKeyReport1Bis)
+ ->queue($missingKeyReport2);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport1->id() => $missingKeyReport1,
+ $missingKeyReport2->id() => $missingKeyReport2,
+ ]);
+ });
+
+ it('does not allows for duplicates when setting the entire queue', function (): void {
+ $project = Transl::config()->projects()->first();
+ $branch1 = Branch::asCurrent('yolo');
+ $branch2 = Branch::asFallback('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+ $missingKeyReport1 = MissingTranslationKeyReport::new($project, $branch1, $missingKey);
+ $missingKeyReport1Bis = MissingTranslationKeyReport::new($project, $branch1, $missingKey);
+ $missingKeyReport2 = MissingTranslationKeyReport::new($project, $branch2, $missingKey);
+
+ $missingKeys = (new MissingTranslationKeys())->setQueue([
+ $missingKeyReport1,
+ $missingKeyReport1Bis,
+ $missingKeyReport2,
+ ]);
+
+ expect($missingKeys->queued())->toEqual([
+ $missingKeyReport1->id() => $missingKeyReport1,
+ $missingKeyReport2->id() => $missingKeyReport2,
+ ]);
+ });
+});
+
+describe('reporting', function (): void {
+ it('uses `SendMissingTranslationKeyReportAction` to report back to Transl', function (): void {
+ app()->singleton(SendMissingTranslationKeyReportAction::class, function (): SendMissingTranslationKeyReportAction {
+ return new class () extends SendMissingTranslationKeyReportAction {
+ public readonly bool $used;
+
+ public function execute(array $reports): void
+ {
+ $this->used = true;
+ }
+ };
+ });
+
+ (new MissingTranslationKeys())->add('auth.password', [], 'en', true)->report();
+
+ expect(app(SendMissingTranslationKeyReportAction::class)->used)->toEqual(true);
+ });
+
+ it('does not report back to Transl if the queue is empty', function (): void {
+ app()->singleton(SendMissingTranslationKeyReportAction::class, function (): SendMissingTranslationKeyReportAction {
+ return new class () extends SendMissingTranslationKeyReportAction {
+ public bool $used = false;
+
+ public function execute(array $reports): void
+ {
+ $this->used = true;
+ }
+ };
+ });
+
+ (new MissingTranslationKeys())->report();
+
+ expect(app(SendMissingTranslationKeyReportAction::class)->used)->toEqual(false);
+ });
+
+ it('flushes the queue after successfully reporting back to Transl', function (): void {
+ app()->singleton(SendMissingTranslationKeyReportAction::class, function (): SendMissingTranslationKeyReportAction {
+ return new class () extends SendMissingTranslationKeyReportAction {
+ public readonly bool $used;
+
+ public function execute(array $reports): void
+ {
+ $this->used = true;
+ }
+ };
+ });
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true);
+
+ expect(empty($missingKeys->queued()))->toEqual(false);
+
+ $missingKeys->report();
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+
+ it('flushes the queue after failed attempt at reporting back to Transl', function (): void {
+ app()->singleton(SendMissingTranslationKeyReportAction::class, static function (): SendMissingTranslationKeyReportAction {
+ return new class () extends SendMissingTranslationKeyReportAction {
+ public function execute(array $reports): void
+ {
+ throw new Exception('Oops');
+ }
+ };
+ });
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true);
+
+ expect(empty($missingKeys->queued()))->toEqual(false);
+
+ expect(static fn () => $missingKeys->report())->toThrow(Exception::class);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+});
+
+describe('catching missing translation keys (Lang::handleMissingKeysUsing)', function (): void {
+ it('does nothing when the feature is disabled', function (): void {
+ config()->set('transl.reporting.should_report_missing_translation_keys', false);
+
+ Configuration::refreshInstance(config('transl'));
+
+ __('nope');
+
+ expect(app(MissingTranslationKeys::class)->queued())->toEqual([]);
+
+ config()->set('transl.reporting.should_report_missing_translation_keys', true);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('does nothing when no valid handler is provided', function (): void {
+ config()->set('transl.reporting.report_missing_translation_keys_using', '');
+
+ Configuration::refreshInstance(config('transl'));
+
+ __('nope');
+
+ expect(app(MissingTranslationKeys::class)->queued())->toEqual([]);
+
+ config()->set('transl.reporting.report_missing_translation_keys_using', ReportMissingTranslationKeysAction::class);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('does nothing if the Translator does not support the feature', function (): void {
+ app()->singleton('translator', static function (): TranslatorContract {
+ return new class () implements TranslatorContract {
+ public function get($key, array $replace = [], $locale = null): void
+ {
+ //
+ }
+
+ public function choice($key, $number, array $replace = [], $locale = null): void
+ {
+ //
+ }
+
+ public function getLocale(): void
+ {
+ //
+ }
+
+ public function setLocale($locale): void
+ {
+ //
+ }
+
+ public function addNamespace($namespace, $hint): void
+ {
+ //
+ }
+ };
+ });
+
+ __('nope');
+
+ expect(app(MissingTranslationKeys::class)->queued())->toEqual([]);
+ });
+
+ it('is able to catch missing translation keys if the Translator supports the feature', function (): void {
+ app()->singleton('translator', static function (): TranslatorContract {
+ return new class () implements TranslatorContract {
+ protected Closure $missingTranslationKeyCallback;
+
+ public function get($key, array $replace = [], $locale = null): void
+ {
+ $this->handleMissingTranslationKey($key, $replace, $locale, false);
+ }
+
+ public function choice($key, $number, array $replace = [], $locale = null): void
+ {
+ //
+ }
+
+ public function getLocale(): void
+ {
+ //
+ }
+
+ public function setLocale($locale): void
+ {
+ //
+ }
+
+ public function addNamespace($namespace, $hint): void
+ {
+ //
+ }
+
+ public function handleMissingKeysUsing(?callable $callback)
+ {
+ $this->missingTranslationKeyCallback = $callback;
+
+ return $this;
+ }
+
+ protected function handleMissingTranslationKey($key, $replace, $locale, $fallback): void
+ {
+ ($this->missingTranslationKeyCallback)($key, $replace, $locale, $fallback);
+ }
+ };
+ });
+
+ __('nope', ['yep' => 'yolo'], 'ht');
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent(Git::currentBranchName());
+
+ $missingKey = MissingTranslationKey::new('nope', ['yep' => 'yolo'], 'ht', false);
+ $missingKeyReport = MissingTranslationKeyReport::new($project, $branch, $missingKey);
+
+ expect(app(MissingTranslationKeys::class)->queued())->toEqual([
+ $missingKeyReport->id() => $missingKeyReport,
+ ]);
+ });
+
+ it('uses `ReportMissingTranslationKeysAction` to report missing translation keys', function (): void {
+ app()->singleton(ReportMissingTranslationKeysAction::class, function (): ReportMissingTranslationKeysAction {
+ return new class () extends ReportMissingTranslationKeysAction {
+ public readonly bool $used;
+
+ public function execute(string $key, array $replacements, string $locale, bool $fallback): string
+ {
+ $this->used = true;
+
+ return '';
+ }
+ };
+ });
+
+ __('nope');
+
+ expect(app(ReportMissingTranslationKeysAction::class)->used)->toEqual(true);
+ });
+
+ it('correctly catches missing translation keys', function (): void {
+ __('auth.password');
+ __('nope');
+ __('nope', ['yo' => 'yolo'], 'ht');
+
+ $project = Transl::config()->projects()->first();
+ $branch = Branch::asCurrent(Git::currentBranchName());
+
+ $missingKey1 = MissingTranslationKey::new('nope', [], 'en', true);
+ $missingKey2 = MissingTranslationKey::new('nope', ['yo' => 'yolo'], 'ht', true);
+ $missingKeyReport1 = MissingTranslationKeyReport::new($project, $branch, $missingKey1);
+ $missingKeyReport2 = MissingTranslationKeyReport::new($project, $branch, $missingKey2);
+
+ expect(app(MissingTranslationKeys::class)->queued())->toEqual([
+ $missingKeyReport1->id() => $missingKeyReport1,
+ $missingKeyReport2->id() => $missingKeyReport2,
+ ]);
+ });
+
+ it('returns the missing translation key as usual even after correctly catching it', function (): void {
+ expect(__('nope'))->toEqual('nope');
+
+ expect(empty(app(MissingTranslationKeys::class)->queued()))->toEqual(false);
+ });
+
+ it('uses the configured handler to report missing translation keys', function (): void {
+ $class = new class () {
+ public readonly bool $used;
+
+ public function execute(): void
+ {
+ $this->used = true;
+ }
+ };
+
+ app()->singleton($class::class);
+
+ config()->set('transl.reporting.report_missing_translation_keys_using', $class::class);
+
+ Configuration::refreshInstance(config('transl'));
+
+ __('nope');
+
+ expect(app($class::class)->used)->toEqual(true);
+
+ config()->set('transl.reporting.report_missing_translation_keys_using', ReportMissingTranslationKeysAction::class);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it("is able to use the configured handler's \"__invoke\" method to report missing translation keys", function (): void {
+ $class = new class () {
+ public readonly bool $used;
+
+ public function __invoke(): void
+ {
+ $this->used = true;
+ }
+ };
+
+ app()->singleton($class::class);
+
+ config()->set('transl.reporting.report_missing_translation_keys_using', $class::class);
+
+ Configuration::refreshInstance(config('transl'));
+
+ __('nope');
+
+ expect(app($class::class)->used)->toEqual(true);
+
+ config()->set('transl.reporting.report_missing_translation_keys_using', ReportMissingTranslationKeysAction::class);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it("favors the configured handler's \"execute\" method over \"__invoke\" to report missing translation keys", function (): void {
+ $class = new class () {
+ public readonly bool $invoked;
+ public readonly bool $executed;
+
+ public function execute(): void
+ {
+ $this->executed = true;
+ }
+
+ public function __invoke(): void
+ {
+ $this->invoked = true;
+ }
+ };
+
+ app()->singleton($class::class);
+
+ config()->set('transl.reporting.report_missing_translation_keys_using', $class::class);
+
+ Configuration::refreshInstance(config('transl'));
+
+ __('nope');
+
+ expect(app($class::class)->executed)->toEqual(true);
+ expect(static fn () => app($class::class)->invoked)->toThrow('$invoked must not be accessed before initialization');
+
+ config()->set('transl.reporting.report_missing_translation_keys_using', ReportMissingTranslationKeysAction::class);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+});
+
+/* Failures (throwing)
+------------------------------------------------*/
+
+describe('fails queueing with a given unknown project "auth_key" or "name"', function (): void {
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->add('auth.password', [], 'en', true, 'nope', $branch),
+ )->toThrow(CouldNotDetermineProject::class);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->register($missingKey, 'nope', $branch),
+ )->toThrow(CouldNotDetermineProject::class);
+ });
+});
+
+describe('fails queueing with a given duplicate project "auth_key" or "name"', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.projects', [
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'first_name',
+ ],
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'second_name',
+ ],
+ ]);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->add('auth.password', [], 'en', true, 'duplicate_auth_key', $branch),
+ )->toThrow(MultipleProjectsFound::class);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->register($missingKey, 'duplicate_auth_key', $branch),
+ )->toThrow(MultipleProjectsFound::class);
+ });
+});
+
+describe('fails queueing when no project can be guessed', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.projects', []);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->add('auth.password', [], 'en', true, null, $branch),
+ )->toThrow(CouldNotBuildReport::class);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->register($missingKey, null, $branch),
+ )->toThrow(CouldNotBuildReport::class);
+ });
+});
+
+describe('fails queueing when no project can be guessed out of duplicate projects', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.projects', [
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'first_name',
+ ],
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'second_name',
+ ],
+ ]);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->add('auth.password', [], 'en', true, null, $branch),
+ )->toThrow(MultipleProjectsFound::class);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ expect(
+ static fn () => (new MissingTranslationKeys())->register($missingKey, null, $branch),
+ )->toThrow(MultipleProjectsFound::class);
+ });
+});
+
+describe('fails catching missing translation keys (Lang::handleMissingKeysUsing)', function (): void {
+ it('is able to throw back thrown exceptions', function (): void {
+ app()->singleton('translator', static function (): TranslatorContract {
+ return new class (app('translation.loader'), 'jp') extends Translator {
+ public function handleMissingKeysUsing(?callable $callback): void
+ {
+ throw new Exception('Oops');
+ }
+ };
+ });
+
+ expect(static fn () => __('nope'))->toThrow(Exception::class);
+ });
+});
+
+/* Failures (silent)
+------------------------------------------------*/
+
+describe('silently fails queueing with a given unknown project "auth_key" or "name"', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.reporting.silently_discard_exceptions', true);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, 'nope', $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, 'nope', $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+});
+
+describe('silently fails queueing with a given duplicate project "auth_key" or "name"', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.reporting.silently_discard_exceptions', true);
+
+ Configuration::refreshInstance(config('transl'));
+
+ config()->set('transl.projects', [
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'first_name',
+ ],
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'second_name',
+ ],
+ ]);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, 'duplicate_auth_key', $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, 'duplicate_auth_key', $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+});
+
+describe('silently fails queueing when no project can be guessed', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.reporting.silently_discard_exceptions', true);
+ config()->set('transl.projects', []);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, null, $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, null, $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+});
+
+describe('silently fails queueing when no project can be guessed out of duplicate projects', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.reporting.silently_discard_exceptions', true);
+
+ config()->set('transl.projects', [
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'first_name',
+ ],
+ [
+ 'auth_key' => 'duplicate_auth_key',
+ 'name' => 'second_name',
+ ],
+ ]);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('cannot add a raw missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKeys = (new MissingTranslationKeys())->add('auth.password', [], 'en', true, null, $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+
+ it('cannot register a missing translation key', function (): void {
+ $branch = Branch::asCurrent('yolo');
+
+ $missingKey = MissingTranslationKey::new('auth.password', [], 'en', true);
+
+ $missingKeys = (new MissingTranslationKeys())->register($missingKey, null, $branch);
+
+ expect($missingKeys->queued())->toEqual([]);
+ });
+});
+
+describe('silently fails catching missing translation keys (Lang::handleMissingKeysUsing)', function (): void {
+ beforeEach(function (): void {
+ config()->set('transl.reporting.silently_discard_exceptions', true);
+
+ Configuration::refreshInstance(config('transl'));
+ });
+
+ it('is able to NOT throw back thrown exceptions', function (): void {
+ app()->singleton('translator', static function (): TranslatorContract {
+ return new class (app('translation.loader'), 'jp') extends Translator {
+ public function handleMissingKeysUsing(?callable $callback): void
+ {
+ throw new Exception('Oops');
+ }
+ };
+ });
+
+ expect(__('nope'))->toEqual('nope');
+
+ expect(empty(app(MissingTranslationKeys::class)->queued()))->toEqual(true);
+ });
+});
diff --git a/tests/src/Support/TranslationLinesDiffingTest.php b/tests/src/Support/TranslationLinesDiffingTest.php
new file mode 100644
index 0000000..f0f1422
--- /dev/null
+++ b/tests/src/Support/TranslationLinesDiffingTest.php
@@ -0,0 +1,985 @@
+makeLine = static function (
+ string $key,
+ mixed $value,
+ ?array $meta = null,
+ ): TranslationLine {
+ return TranslationLine::from([
+ 'key' => $key,
+ 'value' => $value,
+ 'meta' => $meta,
+ ]);
+ };
+ $this->makeSet = static function (
+ TranslationLineCollection|array $lines,
+ string $locale = 'en',
+ ?string $group = 'test',
+ ?string $namespace = null,
+ ?array $meta = null,
+ ): TranslationSet {
+ return TranslationSet::from([
+ 'locale' => $locale,
+ 'group' => $group,
+ 'namespace' => $namespace,
+ 'lines' => $lines,
+ 'meta' => $meta,
+ ]);
+ };
+
+ $this->addSetLine = function (
+ TranslationSet $set,
+ string $lineKey,
+ string $newValue,
+ ?array $newMeta = null,
+ ): TranslationSet {
+ $line = $this->makeLine->__invoke($lineKey, $newValue, $newMeta);
+
+ return TranslationSet::from([
+ ...$set->toArray(),
+ 'lines' => $set->lines->toBase()->push($line),
+ ]);
+ };
+ $this->updateSetLine = function (
+ TranslationSet $set,
+ string $lineKey,
+ mixed $newValue,
+ ?array $newMeta = null,
+ ): TranslationSet {
+ $line = $set->lines->firstWhere('key', $lineKey);
+ $line = $this->makeLine->__invoke($line->key, $newValue, $newMeta ?: $line->meta);
+
+ return TranslationSet::from([
+ ...$set->toArray(),
+ 'lines' => $set->lines->map(static function (TranslationLine $item) use ($line): TranslationLine {
+ if ($line->key === $item->key) {
+ return $line;
+ }
+
+ return $item;
+ }),
+ ]);
+ };
+ $this->removeSetLine = static function (TranslationSet $set, string $lineKey): TranslationSet {
+ $line = $set->lines->firstWhere('key', $lineKey);
+
+ return TranslationSet::from([
+ ...$set->toArray(),
+ 'lines' => $set->lines->filter(static function (TranslationLine $item) use ($line): bool {
+ return $line->key !== $item->key;
+ }),
+ ]);
+ };
+ $this->updateSetLineKey = static function (
+ TranslationSet $set,
+ string $lineKey,
+ string $newKey,
+ ): TranslationSet {
+ return TranslationSet::from([
+ ...$set->toArray(),
+ 'lines' => $set->lines->map(static function (TranslationLine $item) use ($lineKey, $newKey): TranslationLine {
+ if ($lineKey !== $item->key) {
+ return $item;
+ }
+
+ return TranslationLine::from([
+ ...$item->toArray(),
+ 'key' => $newKey,
+ ]);
+ }),
+ ]);
+ };
+
+ $this->tracked = $this->makeSet->__invoke([
+ $this->makeLine->__invoke('email', 'Tracked "email" value.'),
+ $this->makeLine->__invoke('first_name', 'Tracked "first_name" value.'),
+ $this->makeLine->__invoke('last_name', 'Tracked "last_name" value.'),
+ $this->makeLine->__invoke('password', 'Tracked "password" value.'),
+ ]);
+});
+
+describe('Base', function (): void {
+ it('implements the `Arrayable` contract', function (): void {
+ expect(in_array(Arrayable::class, class_implements(TranslationLinesDiffing::class), true))->toEqual(true);
+ });
+
+ it('can be represented as an array', function (): void {
+ $current = clone $this->tracked;
+ $incoming = clone $this->tracked;
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->toArray())->toEqual([
+ 'tracked_lines' => $this->tracked->lines->toArray(),
+ 'current_lines' => $current->lines->toArray(),
+ 'incoming_lines' => $incoming->lines->toArray(),
+ ]);
+ });
+});
+
+describe('Misc.', function (): void {
+ it('can handle lines with empty array values', function (): void {
+ $current = $this->updateSetLine->__invoke(clone $this->tracked, 'email', []);
+ $incoming = clone $this->tracked;
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+});
+
+/* Values
+------------------------------------------------*/
+
+describe('Values | Same', function (): void {
+ /* Same | Same
+ ------------------------------------------------*/
+
+ test('[tracked | current:same | incoming:same]', function (): void {
+ $current = clone $this->tracked;
+ $incoming = clone $this->tracked;
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual($this->tracked->lines->toRawTranslationLines());
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ /* Same | Changed
+ ------------------------------------------------*/
+
+ test('[tracked | current:same | incoming:changed]', function (): void {
+ $current = clone $this->tracked;
+ $incoming = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Incoming "email" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ test('[tracked | current:changed | incoming:same]', function (): void {
+ $current = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Current "email" value.');
+ $incoming = clone $this->tracked;
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ /* Same | Added
+ ------------------------------------------------*/
+
+ test('[tracked | current:same | incoming:added]', function (): void {
+ $current = clone $this->tracked;
+ $incoming = $this->addSetLine->__invoke(clone $this->tracked, 'username', 'Incoming "username" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual($this->tracked->lines->toRawTranslationLines());
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ test('[tracked | current:added | incoming:same]', function (): void {
+ $current = $this->addSetLine->__invoke(clone $this->tracked, 'username', 'Current "username" value.');
+ $incoming = clone $this->tracked;
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual($this->tracked->lines->toRawTranslationLines());
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Current "username" value.',
+ ...$incoming->lines->toRawTranslationLines(),
+ ]);
+ });
+
+ /* Same | Removed
+ ------------------------------------------------*/
+
+ test('[tracked | current:same | incoming:removed]', function (): void {
+ $current = clone $this->tracked;
+ $incoming = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ test('[tracked | current:removed | incoming:same]', function (): void {
+ $current = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+ $incoming = clone $this->tracked;
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual(
+ collect($current->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+ });
+});
+
+describe('Values | Changed', function (): void {
+ /* Changed | Changed
+ ------------------------------------------------*/
+
+ test('[tracked | current:changed | incoming:changed]', function (): void {
+ $current = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Current "email" value.');
+ $incoming = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Incoming "email" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ test('[tracked | current:changed | incoming:changed (same change)]', function (): void {
+ $current = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Current/Incoming "email" value.');
+ $incoming = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Current/Incoming "email" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ /* Changed | Added
+ ------------------------------------------------*/
+
+ test('[tracked | current:changed | incoming:added]', function (): void {
+ $current = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Current "email" value.');
+ $incoming = $this->addSetLine->__invoke(clone $this->tracked, 'username', 'Incoming "username" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual([
+ ...collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ test('[tracked | current:added | incoming:changed]', function (): void {
+ $current = $this->addSetLine->__invoke(clone $this->tracked, 'username', 'Current "username" value.');
+ $incoming = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Incoming "email" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Current "username" value.',
+ ...$incoming->lines->toRawTranslationLines(),
+ ]);
+ });
+
+ /* Changed | Removed
+ ------------------------------------------------*/
+
+ test('[tracked | current:changed | incoming:removed]', function (): void {
+ $current = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Current "email" value.');
+ $incoming = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+
+ test('[tracked | current:removed | incoming:changed]', function (): void {
+ $current = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+ $incoming = $this->updateSetLine->__invoke(clone $this->tracked, 'email', 'Incoming "email" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Incoming "email" value.',
+ ]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+});
+
+describe('Values | Added', function (): void {
+ /* Added | Added
+ ------------------------------------------------*/
+
+ test('[tracked | current:added | incoming:added]', function (): void {
+ $current = $this->addSetLine->__invoke(clone $this->tracked, 'username', 'Current "username" value.');
+ $incoming = $this->addSetLine->__invoke(clone $this->tracked, 'username_bis', 'Incoming "username_bis" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'username_bis' => 'Incoming "username_bis" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual($this->tracked->lines->toRawTranslationLines());
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([
+ 'username_bis' => 'Incoming "username_bis" value.',
+ ]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([
+ 'username_bis' => 'Incoming "username_bis" value.',
+ ]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'username_bis' => 'Incoming "username_bis" value.',
+ ]);
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'username_bis' => 'Incoming "username_bis" value.',
+ ]);
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Current "username" value.',
+ ...$incoming->lines->toRawTranslationLines(),
+ ]);
+ });
+
+ /* Added | Removed
+ ------------------------------------------------*/
+
+ test('[tracked | current:added | incoming:removed]', function (): void {
+ $current = $this->addSetLine->__invoke(clone $this->tracked, 'username', 'Current "username" value.');
+ $incoming = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual(
+ collect($current->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Current "username" value.',
+ ...$incoming->lines->toRawTranslationLines(),
+ ]);
+ });
+
+ test('[tracked | current:removed | incoming:added]', function (): void {
+ $current = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+ $incoming = $this->addSetLine->__invoke(clone $this->tracked, 'username', 'Incoming "username" value.');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'username' => 'Incoming "username" value.',
+ ]);
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+});
+
+describe('Values | Removed', function (): void {
+ /* Removed | Removed
+ ------------------------------------------------*/
+
+ test('[tracked | current:removed | incoming:removed]', function (): void {
+ $current = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+ $incoming = $this->removeSetLine->__invoke(clone $this->tracked, 'email');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual($current->lines->toRawTranslationLines());
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+});
+
+/* Keys
+------------------------------------------------*/
+
+describe('Keys', function (): void {
+ it('treats key updates as 1 deletion & 1 addition', function (): void {
+ $current = clone $this->tracked;
+ $incoming = $this->updateSetLineKey->__invoke(clone $this->tracked, 'email', 'e-mail');
+
+ $diff = TranslationLinesDiffing::new(
+ trackedLines: $this->tracked->lines,
+ currentLines: $current->lines,
+ incomingLines: $incoming->lines,
+ );
+
+ expect($diff->changedLines()->toRawTranslationLines())->toEqual([
+ 'e-mail' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->updatedLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->sameLines()->toRawTranslationLines())->toEqual(
+ collect($this->tracked->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->addedLines()->toRawTranslationLines())->toEqual([
+ 'e-mail' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->removedLines()->toRawTranslationLines())->toEqual([
+ 'email' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->conflictingLines()->toRawTranslationLines())->toEqual([]);
+
+ expect($diff->nonConflictingLines()->toRawTranslationLines())->toEqual([
+ 'e-mail' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->safeLines()->toRawTranslationLines())->toEqual(
+ collect($incoming->lines->toRawTranslationLines())->forget('email')->toArray(),
+ );
+
+ expect($diff->mergeableLines()->toRawTranslationLines())->toEqual([
+ ...collect($current->lines->toRawTranslationLines())->forget('email')->toArray(),
+ 'e-mail' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->favorCurrentLines()->toRawTranslationLines())->toEqual([
+ ...$current->lines->toRawTranslationLines(),
+ 'e-mail' => 'Tracked "email" value.',
+ ]);
+
+ expect($diff->favorIncomingLines()->toRawTranslationLines())->toEqual($incoming->lines->toRawTranslationLines());
+ });
+});