From 495f6a96e45905046a927fa41829ff5e33fe21e6 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sun, 17 Dec 2023 08:56:58 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 14 + .github/CONTRIBUTING.md | 27 ++ .github/FUNDING.yml | 2 + .github/ISSUE_TEMPLATE/1_Bug_report.md | 21 + .github/ISSUE_TEMPLATE/2_Feature_request.md | 12 + .../ISSUE_TEMPLATE/3_Documentation_issue.md | 5 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/PULL_REQUEST_TEMPLATE.md | 19 + .github/dependabot.yml | 8 + .github/stale.yml | 8 + .github/workflows/coding-standards.yml | 32 ++ .github/workflows/rector_checkstyle.yaml | 29 ++ .github/workflows/static-analyze.yml | 32 ++ .github/workflows/tests.yml | 32 ++ .gitignore | 3 + CODE_OF_CONDUCT.md | 46 ++ LICENSE | 21 + Makefile | 52 +++ README.md | 80 ++++ SECURITY.md | 13 + composer.json | 60 +++ ecs.php | 104 +++++ infection.json.dist | 19 + phpstan-baseline.neon | 0 phpstan.neon | 11 + phpunit.xml.dist | 31 ++ rector.php | 34 ++ src/Command/GenerateManifestCommand.php | 401 ++++++++++++++++++ src/DependencyInjection/Configuration.php | 368 ++++++++++++++++ .../SpomkyLabsPwaExtension.php | 42 ++ src/ImageProcessor/GDImageProcessor.php | 31 ++ src/ImageProcessor/ImageProcessor.php | 15 + src/ImageProcessor/ImagickImageProcessor.php | 42 ++ src/Resources/config/services.php | 26 ++ src/SpomkyLabsPwaBundle.php | 16 + tests/AppKernel.php | 32 ++ tests/Functional/CommandTest.php | 37 ++ tests/config.php | 167 ++++++++ tests/images/1920x1920.svg | 1 + tests/images/360x800.svg | 1 + tests/images/390x844.svg | 1 + tests/images/600x400.svg | 1 + tests/images/780x360.svg | 1 + tests/images/915x412.svg | 1 + 44 files changed, 1903 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/1_Bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/2_Feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/3_Documentation_issue.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/stale.yml create mode 100644 .github/workflows/coding-standards.yml create mode 100644 .github/workflows/rector_checkstyle.yaml create mode 100644 .github/workflows/static-analyze.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 composer.json create mode 100644 ecs.php create mode 100644 infection.json.dist create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 rector.php create mode 100644 src/Command/GenerateManifestCommand.php create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/DependencyInjection/SpomkyLabsPwaExtension.php create mode 100644 src/ImageProcessor/GDImageProcessor.php create mode 100644 src/ImageProcessor/ImageProcessor.php create mode 100644 src/ImageProcessor/ImagickImageProcessor.php create mode 100644 src/Resources/config/services.php create mode 100644 src/SpomkyLabsPwaBundle.php create mode 100644 tests/AppKernel.php create mode 100644 tests/Functional/CommandTest.php create mode 100644 tests/config.php create mode 100644 tests/images/1920x1920.svg create mode 100644 tests/images/360x800.svg create mode 100644 tests/images/390x844.svg create mode 100644 tests/images/600x400.svg create mode 100644 tests/images/780x360.svg create mode 100644 tests/images/915x412.svg diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..90c4a5d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto + +/.github export-ignore +/doc export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php_cs.dist export-ignore +/.scrutinizer.yml export-ignore +/.travis.yml export-ignore +/CODE_OF_CONDUCT.md export-ignore +/README.md export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..21ab573 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +First of all, **thank you** for contributing. + +Bugs or feature requests can be posted online on the GitHub issues section of the project. + +Few rules to ease code reviews and merges: + +- You MUST follow the [PSR-12](http://www.php-fig.org/psr/psr-12/) coding standards. +- You MUST run the test suite. +- You MUST write (or update) unit tests when bugs are fixed or features are added. +- You SHOULD write documentation. + +To contribute use [Pull Requests](https://help.github.com/articles/using-pull-requests), please, write commit messages that make sense, and rebase your branch before submitting your PR. + +May be asked to squash your commits too. This is used to "clean" your Pull Request before merging it, avoiding commits such as fix tests, fix 2, fix 3, etc. + +Run test suite +------------ + +* install composer: `curl -s http://getcomposer.org/installer | php` +* install dependencies: `php composer.phar install` +* run tests: `vendor/bin/phpunit` +* check and fix coding standards: + * `vendor/bin/phpstan analyse` + * `vendor/bin/rector process` + * `vendor/bin/ecs check --fix` diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..726574c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: Spomky +patreon: FlorentMorselli diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.md b/.github/ISSUE_TEMPLATE/1_Bug_report.md new file mode 100644 index 0000000..3a3e9fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_Bug_report.md @@ -0,0 +1,21 @@ +--- +name: 🐛 Bug Report +about: ⚠️ See below for security reports +labels: Bug + +--- + +**Version(s) affected**: x.y.z + +**Description** + + +**How to reproduce** + + +**Possible Solution** + + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/2_Feature_request.md b/.github/ISSUE_TEMPLATE/2_Feature_request.md new file mode 100644 index 0000000..8ffd974 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_Feature_request.md @@ -0,0 +1,12 @@ +--- +name: 🚀 Feature Request +about: Ideas for new features and improvements + +--- + +**Description** + + +**Example** + diff --git a/.github/ISSUE_TEMPLATE/3_Documentation_issue.md b/.github/ISSUE_TEMPLATE/3_Documentation_issue.md new file mode 100644 index 0000000..26cd199 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_Documentation_issue.md @@ -0,0 +1,5 @@ +--- +name: 📖 Documentation Issue +about: To report typo or obsolete section in the documentation + +--- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..dde06cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Support Question + url: https://spomky-labs.com/contact/ + about: We use GitHub issues only to discuss about bugs and new features. For this kind of questions about using the library, please use Stackoverflow (or similar) or send a quote request at https://spomky-labs.com/contact/ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1beb0a3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +| Q | A +| ------------- | --- +| Branch? | +| Bug fix? | yes/no +| New feature? | yes/no +| Deprecations? | yes/no +| Tickets | Fix #... +| License | MIT + \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5a98fda --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: daily + time: "04:00" + open-pull-requests-limit: 10 diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..3c84124 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,8 @@ +daysUntilStale: 60 +daysUntilClose: 7 +staleLabel: wontfix +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +closeComment: false diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml new file mode 100644 index 0000000..c8a83b9 --- /dev/null +++ b/.github/workflows/coding-standards.yml @@ -0,0 +1,32 @@ +name: Coding Standards + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest] + php-versions: ['8.2'] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring + coverage: xdebug + + - name: Install Composer dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: CODING STANDARDS + run: make ci-cs diff --git a/.github/workflows/rector_checkstyle.yaml b/.github/workflows/rector_checkstyle.yaml new file mode 100644 index 0000000..9e4a1ac --- /dev/null +++ b/.github/workflows/rector_checkstyle.yaml @@ -0,0 +1,29 @@ +name: Rector Checkstyle + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: ['8.2'] + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring + coverage: none + + - name: Install Composer dependencies + run: composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: Rector + run: make rector diff --git a/.github/workflows/static-analyze.yml b/.github/workflows/static-analyze.yml new file mode 100644 index 0000000..ee4ef59 --- /dev/null +++ b/.github/workflows/static-analyze.yml @@ -0,0 +1,32 @@ +name: Static Analyze + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest] + php-versions: ['8.2'] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring + coverage: xdebug + + - name: Install Composer dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: PHPStan + run: make st diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9e27913 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Unit and Functional Tests + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: ['8.2', '8.3'] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring + coverage: xdebug + + - name: Install Composer dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: Run tests + run: make tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c84ab0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.phpunit.result.cache diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4ec12c7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@spomky-labs.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a098645 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2018 Spomky-Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2e4596f --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +.PHONY: tests +tests: vendor ## Run all tests + vendor/bin/phpunit --color + +.PHONY: code-coverage-html +cc: vendor ## Show test coverage rates (HTML) + vendor/bin/phpunit --coverage-html ./build + +.PHONY: cs +cs: vendor ## Fix all files using defined ECS rules + vendor/bin/ecs check --fix + +.PHONY: tu +tu: vendor ## Run only unit tests + vendor/bin/phpunit --color --group Unit + +.PHONY: ti +ti: vendor ## Run only integration tests + vendor/bin/phpunit --color --group Integration + +.PHONY: tf +tf: vendor ## Run only functional tests + vendor/bin/phpunit --color --group Functional + +.PHONY: st +st: vendor ## Run static analyse + vendor/bin/phpstan analyse + + +################################################ + +.PHONY: ci-cc +ci-cc: vendor ## Show test coverage rates (console) + +.PHONY: ci-cs +ci-cs: vendor ## Check all files using defined ECS rules + vendor/bin/ecs check + +################################################ + + +vendor: composer.json composer.lock + composer validate + composer install +.PHONY: rector +rector: vendor ## Check all files using Rector + vendor/bin/rector process --ansi --dry-run --xdebug + +.DEFAULT_GOAL := help +help: + @grep -E '(^[a-zA-Z_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/' +.PHONY: help diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d18161 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +CBOR Encder/Decoder for Symfony +=============================== + +![Build Status](https://github.com/spomky-labs/cbor-bundle/workflows/Coding%20Standards/badge.svg) +![Build Status](https://github.com/spomky-labs/cbor-bundle/workflows/Static%20Analyze/badge.svg) + +![Build Status](https://github.com/spomky-labs/cbor-bundle/workflows/Unit%20and%20Functional%20Tests/badge.svg) +![Build Status](https://github.com/spomky-labs/cbor-bundle/workflows/Rector%20Checkstyle/badge.svg) + +[![Latest Stable Version](https://poser.pugx.org/spomky-labs/cbor-bundle/v/stable.png)](https://packagist.org/packages/spomky-labs/cbor-bundle) +[![Total Downloads](https://poser.pugx.org/spomky-labs/cbor-bundle/downloads.png)](https://packagist.org/packages/spomky-labs/cbor-bundle) +[![Latest Unstable Version](https://poser.pugx.org/spomky-labs/cbor-bundle/v/unstable.png)](https://packagist.org/packages/spomky-labs/cbor-bundle) +[![License](https://poser.pugx.org/spomky-labs/cbor-bundle/license.png)](https://packagist.org/packages/spomky-labs/cbor-bundle) + +# Scope + +This bundle wraps the [spomky-labs/cbor-php](https://github.com/spomky-labs/cbor-bundle) library and provides the decoder as a service +This will help you to easily decode CBOR streams (Concise Binary Object Representation from [RFC8949](https://datatracker.ietf.org/doc/html/rfc8949)). + +# Installation + +Install the bundle with Composer: `composer require spomky-labs/cbor-bundle`. + +This project follows the [semantic versioning](http://semver.org/) strictly. + +# Documentation + +## Object Creation + +For object creation, please refer to [the documentation of the library](https://github.com/Spomky-Labs/cbor-php#object-creation). + +## Object Loading + +If you want to load a CBOR encoded data, you just have to use de decoder available from the container. + +```php +get(CBORDecoder::class)->decode($data); // Return a CBOR\OtherObject\DoublePrecisionFloatObject class with normalized value ~0.3333 (=1/3) +``` + +## Custom Tags / Other Objects + +*To be written* + +# Support + +I bring solutions to your problems and answer your questions. + +If you really love that project and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! + +[Become a sponsor](https://github.com/sponsors/Spomky) + +Or + +[![Become a Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/FlorentMorselli) + +# Contributing + +Requests for new features, bug fixed and all other ideas to make this project useful are welcome. +The best contribution you could provide is by fixing the [opened issues where help is wanted](https://github.com/spomky-labs/cbor-bundle/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22). + +Please report all issues in [the main repository](https://github.com/spomky-labs/cbor-bundle/issues). + +Please make sure to [follow these best practices](.github/CONTRIBUTING.md). + +# Security Issues + +If you discover a security vulnerability within the project, please **don't use the bug tracker and don't publish it publicly**. +Instead, all security issues must be sent to security [at] spomky-labs.com. + +# Licence + +This project is release under [MIT licence](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c85f447 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| 1.0.x | :white_check_mark: | +| < 1.0.x | :x: | + +## Reporting a Vulnerability + +If you discover a security vulnerability within the project, please **don't use the bug tracker and don't publish it publicly**. +Instead, all security issues must be sent to security [at] spomky-labs.com. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..41cddfb --- /dev/null +++ b/composer.json @@ -0,0 +1,60 @@ +{ + "name": "spomky-labs/phpwa", + "description": "Progressive Web App Manifest Generator Bundle for Symfony.", + "type": "symfony-bundle", + "license": "MIT", + "keywords": ["PWA", "Bundle", "Symfony"], + "homepage": "https://github.com/spomky-labs", + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + },{ + "name": "All contributors", + "homepage": "https://github.com/spomky-labs/phpwa/contributors" + } + ], + "autoload": { + "psr-4": { + "SpomkyLabs\\PwaBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SpomkyLabs\\PwaBundle\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.2", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/filesystem": "6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "6.4|^7.0" + }, + "require-dev": { + "infection/infection": "^0.27", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-beberlei-assert": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^10.0", + "rector/rector": "^0.18", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "phpstan/extension-installer": true, + "infection/extension-installer": true + } + }, + "suggest": { + "ext-gd": "Required to generate icons (or Imagick).", + "ext-imagick": "Required to generate icons (or GD)." + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..b3b5192 --- /dev/null +++ b/ecs.php @@ -0,0 +1,104 @@ +import(SetList::PSR_12); + $config->import(SetList::CLEAN_CODE); + $config->import(SetList::DOCTRINE_ANNOTATIONS); + $config->import(SetList::SPACES); + $config->import(SetList::PHPUNIT); + $config->import(SetList::SYMPLIFY); + $config->import(SetList::ARRAY); + $config->import(SetList::COMMON); + $config->import(SetList::COMMENTS); + $config->import(SetList::CONTROL_STRUCTURES); + $config->import(SetList::DOCBLOCK); + $config->import(SetList::NAMESPACES); + $config->import(SetList::STRICT); + + $config->rule(StrictParamFixer::class); + $config->rule(StrictComparisonFixer::class); + $config->rule(ArrayIndentationFixer::class); + $config->rule(OrderedImportsFixer::class); + $config->rule(ProtectedToPrivateFixer::class); + $config->rule(DeclareStrictTypesFixer::class); + $config->rule(NativeConstantInvocationFixer::class); + $config->rule(MbStrFunctionsFixer::class); + $config->rule(LinebreakAfterOpeningTagFixer::class); + $config->rule(CombineConsecutiveIssetsFixer::class); + $config->rule(CombineConsecutiveUnsetsFixer::class); + $config->rule(CompactNullableTypehintFixer::class); + $config->rule(NoSuperfluousElseifFixer::class); + $config->rule(NoSuperfluousPhpdocTagsFixer::class); + $config->rule(PhpdocTrimConsecutiveBlankLineSeparationFixer::class); + $config->rule(PhpdocOrderFixer::class); + $config->rule(SimplifiedNullReturnFixer::class); + $config->rule(PhpUnitTestCaseStaticMethodCallsFixer::class); + $config->ruleWithConfiguration(ArraySyntaxFixer::class, [ + 'syntax' => 'short', + ]); + $config->ruleWithConfiguration(NativeFunctionInvocationFixer::class, [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + 'strict' => true, + ]); + $config->ruleWithConfiguration(HeaderCommentFixer::class, [ + 'header' => $header, + ]); + $config->ruleWithConfiguration(AlignMultilineCommentFixer::class, [ + 'comment_type' => 'all_multiline', + ]); + $config->ruleWithConfiguration(PhpUnitTestAnnotationFixer::class, [ + 'style' => 'annotation', + ]); + $config->ruleWithConfiguration(GlobalNamespaceImportFixer::class, [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ]); + + $config->skip([ + PhpUnitTestClassRequiresCoversFixer::class, + MethodChainingIndentationFixer::class => [__DIR__.'/src/DependencyInjection/Configuration.php'], + \Symplify\CodingStandard\Fixer\Spacing\MethodChainingNewlineFixer::class => [__DIR__.'/src/DependencyInjection/Configuration.php'], + ]); + + $config->parallel(); + $config->paths([ + __DIR__.'/src', + __DIR__.'/tests', + ]); +}; diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..00a3453 --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,19 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "infection.log" + }, + "mutators": { + "@default": true, + "MBString": { + "settings": { + "mb_substr": false, + "mb_strlen": false + } + } + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..102b06a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +parameters: + level: max + paths: + - src + checkMissingIterableValueType: true + checkGenericClassInNonGenericObjectType: true + checkUninitializedProperties: true + treatPhpDocTypesAsCertain: false +includes: + - vendor/phpstan/phpstan/conf/bleedingEdge.neon + - phpstan-baseline.neon diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..feb46b0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + ./tests + + + + + + + + + + + + ./src + + + ./vendor + ./tests + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..6dd23d5 --- /dev/null +++ b/rector.php @@ -0,0 +1,34 @@ +sets([ + SetList::DEAD_CODE, + LevelSetList::UP_TO_PHP_82, + SymfonyLevelSetList::UP_TO_SYMFONY_63, + SymfonySetList::SYMFONY_CODE_QUALITY, + SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION, + DoctrineSetList::DOCTRINE_CODE_QUALITY, + DoctrineSetList::DOCTRINE_ORM_214, + DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES, + PHPUnitLevelSetList::UP_TO_PHPUNIT_100, + PHPUnitSetList::PHPUNIT_CODE_QUALITY, + PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, + ]); + $config->phpVersion(PhpVersion::PHP_82); + $config->paths([__DIR__ . '/src', __DIR__ . '/tests']); + $config->parallel(); + $config->importNames(); + $config->importShortClasses(); +}; diff --git a/src/Command/GenerateManifestCommand.php b/src/Command/GenerateManifestCommand.php new file mode 100644 index 0000000..fac04bd --- /dev/null +++ b/src/Command/GenerateManifestCommand.php @@ -0,0 +1,401 @@ +mime = MimeTypes::getDefault(); + $this->filesystem = new Filesystem(); + parent::__construct(); + } + + protected function configure(): void + { + $this->addArgument('public_url', InputArgument::OPTIONAL, 'Public URL', '/pwa'); + $this->addArgument('public_folder', InputArgument::OPTIONAL, 'Public folder', $this->rootDir . '/public'); + $this->addArgument('asset_folder', InputArgument::OPTIONAL, 'Asset folder', '/assets'); + $this->addArgument('output', InputArgument::OPTIONAL, 'Output file', 'manifest.json'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('PWA Manifest Generator'); + $manifest = $this->config; + $manifest = array_filter($manifest, static fn ($value) => ($value !== null && $value !== [])); + + $publicUrl = $input->getArgument('public_url'); + $publicFolder = Path::canonicalize($input->getArgument('public_folder')); + $assetFolder = '/' . trim((string) $input->getArgument('asset_folder'), '/'); + $outputFile = '/' . trim((string) $input->getArgument('output'), '/'); + + $this->createDirectory($publicFolder); + + $manifest = $this->processIcons($io, $manifest, $publicUrl, $publicFolder, $assetFolder); + if ($manifest === self::FAILURE) { + return self::FAILURE; + } + $manifest = $this->processScreenshots($io, $manifest, $publicUrl, $publicFolder, $assetFolder); + if ($manifest === self::FAILURE) { + return self::FAILURE; + } + $manifest = $this->processShortcutIcons($io, $manifest, $publicUrl, $publicFolder, $assetFolder); + if ($manifest === self::FAILURE) { + return self::FAILURE; + } + + try { + file_put_contents( + sprintf('%s%s', $publicFolder, $outputFile), + json_encode( + $manifest, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR + ) + ); + } catch (JsonException $exception) { + echo 'An error occurred while creating your directory at ' . $exception->getPath(); + } + + return self::SUCCESS; + } + + private function createDirectory(string $folderPath): void + { + if ($this->filesystem->exists($folderPath)) { + $this->filesystem->remove($folderPath); + } + $this->filesystem->mkdir($folderPath); + } + + /** + * @param array $components + * @return array{src: string, type: string} + */ + private function storeFile( + string $data, + string $publicUrl, + string $publicFolder, + string $assetFolder, + string $type, + array $components + ): array { + $tempFilename = $this->filesystem->tempnam($publicFolder, $type . '-'); + $hash = mb_substr(hash('sha256', $data), 0, 8); + file_put_contents($tempFilename, $data); + $mime = $this->mime->guessMimeType($tempFilename); + $extension = $this->mime->getExtensions($mime); + + if (empty($extension)) { + throw new RuntimeException(sprintf('Unable to guess the extension for the mime type "%s"', $mime)); + } + + $components[] = $hash; + $filename = sprintf('%s/%s.%s', $assetFolder, implode('-', $components), $extension[0]); + $localFilename = sprintf('%s%s', $publicFolder, $filename); + + file_put_contents($localFilename, $data); + $this->filesystem->remove($tempFilename); + + return [ + 'src' => sprintf('%s%s', $publicUrl, $filename), + 'type' => $mime, + ]; + } + + /** + * @return array{src: string, type: string, sizes: string, form_factor: ?string} + */ + private function storeScreenshot( + string $data, + string $publicUrl, + string $publicFolder, + string $assetFolder, + ?string $format, + ?string $formFactor + ): array { + if ($format !== null) { + $data = $this->imageProcessor->process($data, null, null, $format); + } + + ['width' => $width, 'height' => $height] = $this->imageProcessor->getSizes($data); + $size = sprintf('%sx%s', $width, $height); + $formFactor ??= $width > $height ? 'wide' : 'narrow'; + + $fileData = $this->storeFile( + $data, + $publicUrl, + $publicFolder, + $assetFolder, + 'screenshot', + ['screenshot', $formFactor, $size] + ); + + return $fileData + [ + 'sizes' => $size, + 'form_factor' => $formFactor, + ]; + } + + /** + * @return array{src: string, sizes: string, type: string, purpose: ?string} + */ + private function storeShortcutIcon( + string $data, + string $publicUrl, + string $publicFolder, + string $assetFolder, + string $sizes, + ?string $purpose + ): array { + $fileData = $this->storeFile( + $data, + $publicUrl, + $publicFolder, + $assetFolder, + 'shortcut-icon', + ['shortcut-icon', $purpose] + ); + + return ($purpose !== null) + ? $fileData + [ + 'sizes' => $sizes, + 'purpose' => $purpose, + ] + : $fileData + [ + 'sizes' => $sizes, + ]; + } + + /** + * @return array{src: string, sizes: string, type: string, purpose: ?string} + */ + private function storeIcon( + string $data, + string $publicUrl, + string $publicFolder, + string $assetFolder, + string $sizes, + ?string $purpose + ): array { + $fileData = $this->storeFile($data, $publicUrl, $publicFolder, $assetFolder, 'icon', ['icon', $purpose]); + + return ($purpose !== null) + ? $fileData + [ + 'sizes' => $sizes, + 'purpose' => $purpose, + ] + : $fileData + [ + 'sizes' => $sizes, + ]; + } + + private function processIcons( + SymfonyStyle $io, + array $manifest, + mixed $publicUrl, + string $publicFolder, + string $assetFolder + ): array|int { + if ($this->config['icons'] === []) { + return $manifest; + } + + try { + $this->filesystem->mkdir(sprintf('%s%s', $publicFolder, $assetFolder)); + } catch (IOExceptionInterface $exception) { + echo 'An error occurred while creating your directory at ' . $exception->getPath(); + } + $manifest['icons'] = []; + $io->info('Processing icons'); + if ($this->imageProcessor === null) { + $io->error('Image processor not found'); + return self::FAILURE; + } + foreach ($this->config['icons'] as $icon) { + $minSize = min($icon['sizes']); + $maxSize = max($icon['sizes']); + if ($minSize === 0 && $maxSize !== 0) { + $io->error('The icon size 0 ("any") must not be mixed with other sizes'); + return self::FAILURE; + } + $data = file_get_contents($icon['src']); + if ($data === false) { + $io->error(sprintf('Unable to read the icon "%s"', $icon['src'])); + return self::FAILURE; + } + if ($maxSize !== 0) { + $data = $this->imageProcessor->process($data, $maxSize, $maxSize, $icon['format'] ?? null); + } + $sizes = $maxSize === 0 ? 'any' : implode( + ' ', + array_map(static fn (int $size): string => $size . 'x' . $size, $icon['sizes']) + ); + $iconManifest = $this->storeIcon( + $data, + $publicUrl, + $publicFolder, + $assetFolder, + $sizes, + $icon['purpose'] ?? null + ); + $manifest['icons'][] = $iconManifest; + } + + return $manifest; + } + + private function processScreenshots( + SymfonyStyle $io, + array $manifest, + mixed $publicUrl, + string $publicFolder, + string $assetFolder + ): array|int { + if ($this->config['screenshots'] === []) { + return $manifest; + } + try { + $this->filesystem->mkdir(sprintf('%s%s', $publicFolder, $assetFolder)); + } catch (IOExceptionInterface $exception) { + echo 'An error occurred while creating your directory at ' . $exception->getPath(); + } + $manifest['screenshots'] = []; + $io->info('Processing screenshots'); + if ($this->imageProcessor === null) { + $io->error('Image processor not found'); + return self::FAILURE; + } + foreach ($this->config['screenshots'] as $screenshot) { + $data = file_get_contents($screenshot['src']); + if ($data === false) { + $io->error(sprintf('Unable to read the screenshot "%s"', $screenshot['src'])); + return self::FAILURE; + } + $screenshotManifest = $this->storeScreenshot( + $data, + $publicUrl, + $publicFolder, + $assetFolder, + $screenshot['format'] ?? null, + $screenshot['form_factor'] ?? null + ); + if (isset($screenshot['label'])) { + $screenshotManifest['label'] = $screenshot['label']; + } + if (isset($screenshot['platform'])) { + $screenshotManifest['platform'] = $screenshot['platform']; + } + $manifest['screenshots'][] = $screenshotManifest; + } + + return $manifest; + } + + private function processShortcutIcons( + SymfonyStyle $io, + array|int $manifest, + mixed $publicUrl, + string $publicFolder, + string $assetFolder + ): array|int { + if ($this->config['shortcuts'] === []) { + return $manifest; + } + try { + $this->filesystem->mkdir(sprintf('%s%s', $publicFolder, $assetFolder)); + } catch (IOExceptionInterface $exception) { + echo 'An error occurred while creating your directory at ' . $exception->getPath(); + } + $manifest['shortcuts'] = []; + $io->info('Processing schortcuts'); + foreach ($this->config['shortcuts'] as $shortcutConfig) { + $shortcut = $shortcutConfig; + if (isset($shortcut['icons'])) { + unset($shortcut['icons']); + } + if (isset($shortcutConfig['icons'])) { + if (! $this->checkImageProcessor($io)) { + return self::FAILURE; + } + foreach ($shortcutConfig['icons'] as $icon) { + $minSize = min($icon['sizes']); + $maxSize = max($icon['sizes']); + if ($minSize === 0 && $maxSize !== 0) { + $io->error('The icon size 0 ("any") must not be mixed with other sizes'); + return self::FAILURE; + } + + $data = file_get_contents($icon['src']); + if ($data === false) { + $io->error(sprintf('Unable to read the screenshot "%s"', $icon['src'])); + return self::FAILURE; + } + if ($maxSize !== 0) { + $data = $this->imageProcessor->process($data, $maxSize, $maxSize, $icon['format'] ?? null); + } + $sizes = $maxSize === 0 ? 'any' : implode( + ' ', + array_map(static fn (int $size): string => $size . 'x' . $size, $icon['sizes']) + ); + + $iconManifest = $this->storeShortcutIcon( + $data, + $publicUrl, + $publicFolder, + $assetFolder, + $sizes, + $icon['purpose'] ?? null + ); + $shortcut['icons'][] = $iconManifest; + } + } + $manifest['shortcuts'][] = $shortcut; + } + $manifest['shortcuts'] = array_values($manifest['shortcuts']); + + return $manifest; + } + + private function checkImageProcessor(SymfonyStyle $io): bool + { + if ($this->imageProcessor === null) { + $io->error('Image processor not found'); + return false; + } + + return true; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..14e190f --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,368 @@ +alias); + $rootNode = $treeBuilder->getRootNode(); + assert($rootNode instanceof ArrayNodeDefinition); + $rootNode->addDefaultsIfNotSet(); + + $this->setupSimpleOptions($rootNode); + $this->setupIcons($rootNode); + $this->setupScreenshots($rootNode); + $this->setupFileHandlers($rootNode); + $this->setupLaunchHandler($rootNode); + $this->setupProtocolHandlers($rootNode); + $this->setupRelatedApplications($rootNode); + $this->setupShortcuts($rootNode); + $this->setupSharedTarget($rootNode); + + return $treeBuilder; + } + + private function setupShortcuts(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('shortcuts') + ->info('The shortcuts of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired() + ->info('The name of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->scalarNode('short_name') + ->info('The short name of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->scalarNode('description') + ->info('The description of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->scalarNode('url') + ->isRequired() + ->info('The URL of the shortcut.') + ->example('https://example.com') + ->end() + ->append($this->getIconsNode()) + ->end() + ->end() + ->end() + ->end() + ; + } + + private function setupScreenshots(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('screenshots') + ->info('The screenshots of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('src') + ->isRequired() + ->info('The path to the screenshot.') + ->example('screenshot/lowres.webp') + ->end() + ->scalarNode('form_factor') + ->info('The form factor of the screenshot. Will guess the form factor if not set.') + ->example(['wide', 'narrow']) + ->end() + ->scalarNode('label') + ->info('The label of the screenshot.') + ->example('Homescreen of Awesome App') + ->end() + ->scalarNode('platform') + ->info('The platform of the screenshot.') + ->example( + ['android', 'windows', 'chromeos', 'ipados', 'ios', 'kaios', 'macos', 'windows', 'xbox'] + ) + ->end() + ->scalarNode('format') + ->info('The format of the screenshot. Will convert the file if set.') + ->example(['jpg', 'png', 'webp']) + ->end() + ->end() + ->end() + ; + } + + private function setupFileHandlers(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('file_handlers') + ->info( + 'It specifies an array of objects representing the types of files an installed progressive web app (PWA) can handle.' + ) + ->arrayPrototype() + ->children() + ->scalarNode('action') + ->isRequired() + ->info('The action to take.') + ->example('/handle-audio-file') + ->end() + ->arrayNode('accept') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->arrayPrototype() + ->scalarPrototype()->end() + ->end() + ->info('The file types that the action will be applied to.') + ->example('image/*') + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + + private function setupSharedTarget(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('share_target') + ->info('The share target of the application.') + ->children() + ->scalarNode('action') + ->isRequired() + ->info('The action of the share target.') + ->example('/shared-content-receiver/') + ->end() + ->scalarNode('method') + ->info('The method of the share target.') + ->example('GET') + ->end() + ->arrayNode('params') + ->requiresAtLeastOneElement() + ->info('The parameters of the share target.') + ->children() + ->scalarNode('title') + ->info('The title of the share target.') + ->example('name') + ->end() + ->scalarNode('text') + ->info('The text of the share target.') + ->example('description') + ->end() + ->scalarNode('url') + ->info('The URL of the share target.') + ->example('link') + ->end() + ->arrayNode('files') + ->info('The files of the share target.') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + + private function setupIcons(ArrayNodeDefinition $node): void + { + $node->children() + ->append($this->getIconsNode()) + ->end() + ; + } + + private function setupProtocolHandlers(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('protocol_handlers') + ->info('The protocol handlers of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('protocol') + ->isRequired() + ->info('The protocol of the handler.') + ->example('web+jngl') + ->end() + ->scalarNode('url') + ->isRequired() + ->info('The URL of the handler.') + ->example('/lookup?type=%s') + ->end() + ->end() + ->end() + ->end() + ; + } + + private function setupLaunchHandler(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('launch_handler') + ->info('The launch handler of the application.') + ->children() + ->arrayNode('client_mode') + ->info('The client mode of the application.') + ->example(['focus-existing', 'auto']) + ->scalarPrototype()->end() + ->beforeNormalization() + ->castToArray() + ->end() + ->end() + ->end() + ->end() + ; + } + + private function setupRelatedApplications(ArrayNodeDefinition $node): void + { + $node->children() + ->booleanNode('prefer_related_applications') + ->info('The prefer related native applications of the application.') + ->end() + ->arrayNode('related_applications') + ->info('The related applications of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('platform') + ->isRequired() + ->info('The platform of the application.') + ->example('play') + ->end() + ->scalarNode('url') + ->isRequired() + ->info('The URL of the application.') + ->example('https://play.google.com/store/apps/details?id=com.example.app1') + ->end() + ->scalarNode('id') + ->info('The ID of the application.') + ->example('com.example.app1') + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + + private function setupSimpleOptions(ArrayNodeDefinition $node): void + { + $node->children() + ->scalarNode('image_processor') + ->info('The image processor to use to generate the icons of different sizes.') + ->example(GDImageProcessor::class) + ->end() + ->scalarNode('background_color') + ->info( + 'The background color of the application. It should match the background-color CSS property in the sites stylesheet for a smooth transition between launching the web application and loading the site\'s content.' + ) + ->example('red') + ->end() + ->arrayNode('categories') + ->info('The categories of the application.') + ->example([['news', 'sports', 'lifestyle']]) + ->scalarPrototype()->end() + ->end() + ->scalarNode('description') + ->info('The description of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('display') + ->info('The display mode of the application.') + ->example('standalone') + ->end() + ->arrayNode('display_override') + ->info('A sequence of display modes that the browser will consider before using the display member.') + ->example([['fullscreen', 'minimal-ui']]) + ->scalarPrototype()->end() + ->end() + ->scalarNode('id') + ->info('A string that represents the identity of the web application.') + ->example('?homescreen=1') + ->end() + ->scalarNode('orientation') + ->info('The orientation of the application.') + ->example('portrait-primary') + ->end() + ->scalarNode('dir') + ->info('The direction of the application.') + ->example('rtl') + ->end() + ->scalarNode('lang') + ->info('The language of the application.') + ->example('ar') + ->end() + ->scalarNode('name') + ->info('The name of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('short_name') + ->info('The short name of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('scope') + ->info('The scope of the application.') + ->example('/app/') + ->end() + ->scalarNode('start_url') + ->info('The start URL of the application.') + ->example('https://example.com') + ->end() + ->scalarNode('theme_color') + ->info('The theme color of the application.') + ->example('red') + ->end() + ->end() + ; + } + + private function getIconsNode(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('icons'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->info('The icons of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('src') + ->isRequired() + ->info('The path to the icon.') + ->example('icon/logo.svg') + ->end() + ->arrayNode('sizes') + ->info( + 'The sizes of the icon. 16 means 16x16, 32 means 32x32, etc. 0 means "any" (i.e. it is a vector image).' + ) + ->example([['16', '32']]) + ->integerPrototype()->end() + ->end() + ->scalarNode('format') + ->info('The icon format output.') + ->example(['webp', 'png']) + ->end() + ->scalarNode('purpose') + ->info('The purpose of the icon.') + ->example(['any', 'maskable', 'monochrome']) + ->end() + ->end() + ->end() + ; + + return $node; + } +} diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php new file mode 100644 index 0000000..264415a --- /dev/null +++ b/src/DependencyInjection/SpomkyLabsPwaExtension.php @@ -0,0 +1,42 @@ +processConfiguration($this->getConfiguration($configs, $container), $configs); + $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + $loader->load('services.php'); + + if ($config['image_processor'] !== null) { + $container->setAlias(ImageProcessor::class, $config['image_processor']); + } + unset($config['image_processor']); + $container->setParameter('spomky_labs_pwa.config', $config); + } + + public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface + { + return new Configuration(self::ALIAS); + } +} diff --git a/src/ImageProcessor/GDImageProcessor.php b/src/ImageProcessor/GDImageProcessor.php new file mode 100644 index 0000000..95c26ac --- /dev/null +++ b/src/ImageProcessor/GDImageProcessor.php @@ -0,0 +1,31 @@ + $width, 'height' => $height] = $this->getSizes($image); + } + $image = imagecreatefromstring($image); + imagealphablending($image, true); + $image = imagescale($image, $width, $height); + ob_start(); + imagesavealpha($image, true); + imagepng($image); + return ob_get_clean(); + } + + public function getSizes(string $image): array + { + $image = imagecreatefromstring($image); + return [ + 'width' => imagesx($image), + 'height' => imagesy($image), + ]; + } +} diff --git a/src/ImageProcessor/ImageProcessor.php b/src/ImageProcessor/ImageProcessor.php new file mode 100644 index 0000000..d692a33 --- /dev/null +++ b/src/ImageProcessor/ImageProcessor.php @@ -0,0 +1,15 @@ + $width, 'height' => $height] = $this->getSizes($image); + } + $imagick = new Imagick(); + $imagick->readImageBlob($image); + $imagick->resizeImage($width, $height, $this->filters, $this->blur, true); + $imagick->setImageBackgroundColor(new ImagickPixel('transparent')); + if ($format !== null) { + $imagick->setImageFormat($format); + } + return $imagick->getImageBlob(); + } + + public function getSizes(string $image): array + { + $imagick = new Imagick(); + $imagick->readImageBlob($image); + return [ + 'width' => $imagick->getImageWidth(), + 'height' => $imagick->getImageHeight(), + ]; + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php new file mode 100644 index 0000000..3b08283 --- /dev/null +++ b/src/Resources/config/services.php @@ -0,0 +1,26 @@ +services() + ->defaults() + ->private() + ->autoconfigure() + ->autowire() + ; + + $container->set(GenerateManifestCommand::class); + + if (extension_loaded('imagick')) { + $container->set(ImagickImageProcessor::class); + } + if (extension_loaded('gd')) { + $container->set(GDImageProcessor::class); + } +}; diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php new file mode 100644 index 0000000..f1e545b --- /dev/null +++ b/src/SpomkyLabsPwaBundle.php @@ -0,0 +1,16 @@ +load(__DIR__ . '/config.php'); + } +} diff --git a/tests/Functional/CommandTest.php b/tests/Functional/CommandTest.php new file mode 100644 index 0000000..441b752 --- /dev/null +++ b/tests/Functional/CommandTest.php @@ -0,0 +1,37 @@ +find('pwa:build'); + $commandTester = new CommandTester($command); + + // When + $commandTester->execute([ + 'public_folder' => sprintf('%s/samples', $kernel->getCacheDir()), + ]); + + // Then + $commandTester->assertCommandIsSuccessful(); + $output = $commandTester->getDisplay(); + static::assertStringContainsString('PWA Manifest Generator', $output); + } +} diff --git a/tests/config.php b/tests/config.php new file mode 100644 index 0000000..508dc7d --- /dev/null +++ b/tests/config.php @@ -0,0 +1,167 @@ +extension('framework', [ + 'test' => true, + 'secret' => 'test', + 'http_method_override' => true, + 'session' => [ + 'storage_factory_id' => 'session.storage.factory.mock_file', + ], + ]); + $container->extension('pwa', [ + 'image_processor' => ImagickImageProcessor::class, + 'background_color' => 'red', + 'categories' => ['books', 'education', 'medical'], + 'description' => 'Awesome application that will help you achieve your dreams.', + 'display' => 'standalone', + 'display_override' => ['fullscreen', 'minimal-ui'], + 'file_handlers' => [ + [ + 'action' => '/handle-audio-file', + 'accept' => [ + 'audio/wav' => ['.wav'], + 'audio/x-wav' => ['.wav'], + 'audio/mpeg' => ['.mp3'], + 'audio/mp4' => ['.mp4'], + 'audio/aac' => ['.adts'], + 'audio/ogg' => ['.ogg'], + 'application/ogg' => ['.ogg'], + 'audio/webm' => ['.webm'], + 'audio/flac' => ['.flac'], + 'audio/mid' => ['.rmi', '.mid'], + ], + ], + ], + 'icons' => [ + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'sizes' => [48, 72, 96, 128, 256], + 'format' => 'webp', + ], + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'sizes' => [48, 72, 96, 128, 256], + 'format' => 'png', + 'purpose' => 'maskable', + ], + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'sizes' => [0], + ], + ], + 'id' => '?homescreen=1', + 'launch_handler' => [ + 'client_mode' => ['focus-existing', 'auto'], + ], + 'orientation' => 'portrait-primary', + 'prefer_related_applications' => true, + 'dir' => 'rtl', + 'lang' => 'ar', + 'name' => 'تطبيق رائع', + 'short_name' => 'رائع', + 'protocol_handlers' => [ + [ + 'protocol' => 'web+jngl', + 'url' => '/lookup?type=%s', + ], + [ + 'protocol' => 'web+jnglstore', + 'url' => '/shop?for=%s', + ], + ], + 'related_applications' => [ + [ + 'platform' => 'play', + 'url' => 'https://play.google.com/store/apps/details?id=com.example.app1', + 'id' => 'com.example.app1', + ], + [ + 'platform' => 'itunes', + 'url' => 'https://itunes.apple.com/app/example-app1/id123456789', + ], + [ + 'platform' => 'windows', + 'url' => 'https://apps.microsoft.com/store/detail/example-app1/id123456789', + ], + ], + 'scope' => '/app/', + 'start_url' => 'https://example.com', + 'theme_color' => 'red', + 'screenshots' => [ + [ + 'src' => sprintf('%s/images/360x800.svg', __DIR__), + 'label' => 'Homescreen of Awesome App', + 'platform' => 'android', + 'format' => 'png', + ], + [ + 'src' => sprintf('%s/images/390x844.svg', __DIR__), + 'label' => 'List of Awesome Resources available in Awesome App', + 'platform' => 'windows', + 'format' => 'jpeg', + ], + [ + 'src' => sprintf('%s/images/600x400.svg', __DIR__), + 'label' => 'Awesome App in action (1)', + 'format' => 'webp', + ], + [ + 'src' => sprintf('%s/images/780x360.svg', __DIR__), + 'label' => 'Awesome App in action (2)', + 'format' => 'webp', + ], + [ + 'src' => sprintf('%s/images/915x412.svg', __DIR__), + 'label' => 'Awesome App in action (3)', + ], + ], + 'share_target' => [ + 'action' => '/shared-content-receiver/', + 'method' => 'GET', + 'params' => [ + 'title' => 'name', + 'text' => 'description', + 'url' => 'link', + ], + ], + 'shortcuts' => [ + [ + 'name' => "Today's agenda", + 'url' => '/today', + 'description' => 'List of events planned for today', + ], + [ + 'name' => 'New event', + 'url' => '/create/event', + ], + [ + 'name' => 'New reminder', + 'url' => '/create/reminder', + 'icons' => [ + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'sizes' => [48, 72, 96, 128, 256], + 'format' => 'webp', + ], + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'sizes' => [48, 72, 96, 128, 256], + 'format' => 'png', + 'purpose' => 'maskable', + ], + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'sizes' => [0], + ], + ], + ], + ], + ]); +}; diff --git a/tests/images/1920x1920.svg b/tests/images/1920x1920.svg new file mode 100644 index 0000000..ea5be16 --- /dev/null +++ b/tests/images/1920x1920.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/images/360x800.svg b/tests/images/360x800.svg new file mode 100644 index 0000000..27632ed --- /dev/null +++ b/tests/images/360x800.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/images/390x844.svg b/tests/images/390x844.svg new file mode 100644 index 0000000..c89380b --- /dev/null +++ b/tests/images/390x844.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/images/600x400.svg b/tests/images/600x400.svg new file mode 100644 index 0000000..3222db2 --- /dev/null +++ b/tests/images/600x400.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/images/780x360.svg b/tests/images/780x360.svg new file mode 100644 index 0000000..5fcf188 --- /dev/null +++ b/tests/images/780x360.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/images/915x412.svg b/tests/images/915x412.svg new file mode 100644 index 0000000..068fa23 --- /dev/null +++ b/tests/images/915x412.svg @@ -0,0 +1 @@ + \ No newline at end of file