diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 40c6149c1..47ee26c91 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -27,7 +27,7 @@ jobs: - name: 'Validate .github/workflows/build.yaml is up to date' run: |- - docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install + docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install --no-dev docker run --rm -v $PWD:/app -w /app webdevops/php:8.4-alpine ci/console github:generate-ci git diff --exit-code --color=always - @@ -35,6 +35,19 @@ jobs: run: |- docker run --rm -v $PWD:/app -w /app webdevops/php:8.4-alpine ci/console readme-banner:generate git diff --exit-code --color=always + check-ci-code: + name: 'Check ci/ (GrumPHP and PHPUnit)' + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@v7 + with: + persist-credentials: false + - + name: 'Run GrumPHP' + run: |- + docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install + docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine sh -c 'apk add --no-cache file && git config --global --add safe.directory /app && vendor/bin/grumphp run --no-interaction' apache_alpine: strategy: fail-fast: false diff --git a/AGENTS.md b/AGENTS.md index 7dd22f389..6058067d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ | `docker///Dockerfile`, `docker///conf/` | Generated by `make provision`, never edited by hand | | `bin/` | Old Python 2 tools behind `make provision` | | `ci/` | PHP generators for `.github/workflows/build.yaml` (`ci/console github:generate-ci`) and the README banner (`ci/console readme-banner:generate`) | +| `ci/tests/` | PHPUnit tests of `ci/src`; `ci/tests/Fixtures/repository` is a small repository they run against | +| `ci/grumphp.yml`, `ci/phpstan.neon`, `ci/rector.php`, `ci/fractor.php` | Checks of the PHP code in `ci/`, from pluswerk/grumphp-config | | `conf/readme-banner.yml`, `ci/resources/readme-banner/` | Texts and font outlines of the README banner (source) | | `docs/assets/readme-banner-*.svg` | README banner, generated by `ci/console readme-banner:generate`, never edited by hand | | `tests/` | serverspec (Ruby) and container-structure-test definitions run by the CI | @@ -24,6 +26,8 @@ Only these commands are known to work: - `docker run --rm -v $PWD:/app -w /app webdevops/dockerfile-build-env make provision` regenerates `docker/`. Run it after every change to `template/`, `provisioning/` or a `Dockerfile.jinja2`, and commit the result. - `docker build -t webdevops/: docker//` builds one image; its parent and `webdevops/toolbox` come from local images or Docker Hub. - `./build-local.sh [version]` builds all PHP images of one version. +- `docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install` installs the PHP tools of `ci/`. +- `docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine sh -c 'apk add --no-cache file && git config --global --add safe.directory /app && vendor/bin/grumphp run'` checks the PHP code in `ci/`: coding standard, PHPStan, Rector, `composer audit` and the PHPUnit tests. Run it after every change to `ci/`. GrumPHP and PHPStan only check files that Git knows (`git ls-files`), so `git add` new files first; PHPUnit runs every test anyway. `vendor/bin/phpunit` instead of `vendor/bin/grumphp run` runs only the tests. Changes to `ci/src` come with tests. Besides `provision`, the Makefile has `structure-test` and `setup`, which the `webdevops/dockerfile-build-env` image runs while it is built. `make structure-test` runs container-structure-test against your local images of `bootstrap`, `base`, `base-app`, `php`, `php-dev` and `php-nginx`, the images that have a suite in `tests/structure-test`. It tests only the tags in `.github/workflows/build.yaml` and fails if a test fails, if `docker` fails or if no such image exists locally; run it inside `webdevops/dockerfile-build-env` with the Docker socket mounted. The CI runs serverspec for every image except `toolbox` and `dockerfile-build-env`, and container-structure-test for the six images above; to check a change by hand, start the image and try it as described below. diff --git a/README.md b/README.md index 3097ea720..930a1053f 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,15 @@ docker run --rm -ti -v $PWD:/app -w /app webdevops/php:8.4-alpine ci/console rea The banner (`docs/assets/readme-banner-*.svg`) takes the PHP versions, web servers, base OS suffixes, Debian releases and platforms from the images that are built. Its texts are in `conf/readme-banner.yml`; the SVG files are never modified manually. The CI fails if the workflow or the banner is out of date. +The PHP code in `ci/` follows the rules of [pluswerk/grumphp-config](https://github.com/andersundsehr/grumphp-config) (PSR-12, PHPStan level 8, Rector, `composer audit`) and has PHPUnit tests in `ci/tests`. +GrumPHP runs all of them; after a change to `ci/`, run it with: + +``` +docker run --rm -ti -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine sh -c 'apk add --no-cache file && git config --global --add safe.directory /app && vendor/bin/grumphp run' +``` + +The CI job "Check ci/" runs the same checks. No image build waits for it. + GitHub Actions builds every image independently and runs the serverspec and structure tests defined for it before pushing it to Docker Hub. The CI also uses `ghcr.io/webdevops/`, but only internally; pull the images from Docker Hub. diff --git a/ci/composer.json b/ci/composer.json index c1d9a46c2..034ef659a 100644 --- a/ci/composer.json +++ b/ci/composer.json @@ -1,22 +1,46 @@ { - "name": "webdevops/dockerfile", - "type": "project", - "license": "MIT", - "authors": [ - { - "name": "Hans Tuscher", - "email": "hhoechtl@1drop.de" - } - ], - "require": { - "bluem/tree": "^3.1", - "symfony/yaml": "^6.0", - "symfony/console": "^6.0", - "symfony/process": "^6.0" - }, - "autoload": { - "psr-4": { - "Webdevops\\Build\\": "src/" - } + "name": "webdevops/dockerfile", + "license": "MIT", + "type": "project", + "authors": [ + { + "name": "Hans Tuscher", + "email": "hhoechtl@1drop.de" } + ], + "require": { + "php": "^8.4", + "bluem/tree": "^4.0", + "symfony/console": "^6.0", + "symfony/process": "^6.0", + "symfony/yaml": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^12", + "pluswerk/grumphp-config": "^11.2" + }, + "autoload": { + "psr-4": { + "Webdevops\\Build\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Webdevops\\Build\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "a9f/fractor-extension-installer": true, + "ergebnis/composer-normalize": true, + "phpro/grumphp": true, + "phpstan/extension-installer": true, + "pluswerk/grumphp-config": true + } + }, + "extra": { + "grumphp": { + "disable-plugin": true + } + } } diff --git a/ci/composer.lock b/ci/composer.lock index da33ef915..5c446f4f4 100644 --- a/ci/composer.lock +++ b/ci/composer.lock @@ -4,28 +4,31 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "14ae3be283bf9d8b8f243e21e404d706", + "content-hash": "42a2cea5863d51343ea53957a23559a3", "packages": [ { "name": "bluem/tree", - "version": "3.2", + "version": "4.0", "source": { "type": "git", "url": "https://github.com/BlueM/Tree.git", - "reference": "2369895e54397115e99252dde42d929897d6dbc4" + "reference": "0032a48fa1011027886b5def9335c0a9acc81f1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/BlueM/Tree/zipball/2369895e54397115e99252dde42d929897d6dbc4", - "reference": "2369895e54397115e99252dde42d929897d6dbc4", + "url": "https://api.github.com/repos/BlueM/Tree/zipball/0032a48fa1011027886b5def9335c0a9acc81f1a", + "reference": "0032a48fa1011027886b5def9335c0a9acc81f1a", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=7.3 || >=8.0" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": ">=8.0 || >=9.0" + "friendsofphp/php-cs-fixer": "^3.62", + "kubawerlos/php-cs-fixer-custom-fixers": "^3.22", + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^11.0" }, "type": "library", "autoload": { @@ -51,9 +54,10 @@ ], "support": { "issues": "https://github.com/BlueM/Tree/issues", - "source": "https://github.com/BlueM/Tree/tree/3.2" + "source": "https://github.com/BlueM/Tree/tree/4.0" }, - "time": "2021-12-30T15:21:38+00:00" + "time": "2025-08-17T08:10:31+00:00", + "published-time": "2025-08-17T08:12:53+00:00" }, { "name": "psr/container", @@ -832,16 +836,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.45", + "version": "v6.4.50", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "a778aba2d7130eba6150cc80cb692988a361ee4b" + "reference": "0dddc068a050ce2e3608506cd9def5396ee9c626" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/a778aba2d7130eba6150cc80cb692988a361ee4b", - "reference": "a778aba2d7130eba6150cc80cb692988a361ee4b", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0dddc068a050ce2e3608506cd9def5396ee9c626", + "reference": "0dddc068a050ce2e3608506cd9def5396ee9c626", "shasum": "" }, "require": { @@ -884,7 +888,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.45" + "source": "https://github.com/symfony/yaml/tree/v6.4.50" }, "funding": [ { @@ -904,16 +908,7638 @@ "type": "tidelift" } ], - "time": "2026-08-30T00:27:22+00:00" + "time": "2026-11-04T07:42:47+00:00", + "published-time": "2026-11-04T11:25:11+00:00" + } + ], + "packages-dev": [ + { + "name": "a9f/fractor", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-fractor.git", + "reference": "06d30195f9350eb7689e09c152d889828f3a8cef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-fractor/zipball/06d30195f9350eb7689e09c152d889828f3a8cef", + "reference": "06d30195f9350eb7689e09c152d889828f3a8cef", + "shasum": "" + }, + "require": { + "league/flysystem": "^2.0 || ^3.0", + "league/flysystem-memory": "^2.0 || ^3.0", + "nette/utils": "^4.0", + "ondram/ci-detector": "^4.2", + "php": "^8.2", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symplify/rule-doc-generator-contracts": "^11.2", + "webmozart/assert": "^1.11 || ^2.0" + }, + "bin": [ + "bin/fractor" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\Fractor\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Wolf", + "email": "dev@a-w.io", + "role": "Lead Developer" + } + ], + "description": "File Read-Analyse-Change Tool", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "upgrade" + ], + "support": { + "source": "https://github.com/andreaswolf/fractor-fractor/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:09+00:00" + }, + { + "name": "a9f/fractor-extension-installer", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-extension-installer.git", + "reference": "6a058ee03894c50f1859cbdecdc157775600858e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-extension-installer/zipball/6a058ee03894c50f1859cbdecdc157775600858e", + "reference": "6a058ee03894c50f1859cbdecdc157775600858e", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^8.2" + }, + "require-dev": { + "composer/composer": "^2.7" + }, + "type": "composer-plugin", + "extra": { + "class": "a9f\\FractorExtensionInstaller\\InstallerPlugin", + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\FractorExtensionInstaller\\": "src/", + "a9f\\FractorExtensionInstaller\\Generated\\": "generated/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Wolf", + "email": "dev@a-w.io", + "role": "Lead Developer" + } + ], + "description": "Installer for Fractor extensions.", + "support": { + "source": "https://github.com/andreaswolf/fractor-extension-installer/tree/v1.1.0" + }, + "time": "2026-07-08T11:52:14+00:00", + "published-time": "2026-09-25T12:43:05+00:00" + }, + { + "name": "a9f/fractor-fluid", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-fluid.git", + "reference": "366df1b1d9594a7ba06bb51cd0a4bb542779b6a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-fluid/zipball/366df1b1d9594a7ba06bb51cd0a4bb542779b6a0", + "reference": "366df1b1d9594a7ba06bb51cd0a4bb542779b6a0", + "shasum": "" + }, + "require": { + "a9f/fractor": "^1.1", + "a9f/fractor-extension-installer": "^1.1", + "nette/utils": "^4.0", + "php": "^8.2", + "symplify/rule-doc-generator-contracts": "^11.2", + "webmozart/assert": "^1.11 || ^2.0" + }, + "type": "fractor-extension", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\FractorFluid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Wolf", + "email": "dev@a-w.io", + "role": "Lead Developer" + } + ], + "description": "Fluid extension for the File Read-Analyse-Change Tool. Allows modifying Fluid files", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "upgrade" + ], + "support": { + "issues": "https://github.com/andreaswolf/fractor-fluid/issues", + "source": "https://github.com/andreaswolf/fractor-fluid/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:07+00:00" + }, + { + "name": "a9f/fractor-htaccess", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-htaccess.git", + "reference": "73204d0d1c241e11259742a06ca3e85a35699bb2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-htaccess/zipball/73204d0d1c241e11259742a06ca3e85a35699bb2", + "reference": "73204d0d1c241e11259742a06ca3e85a35699bb2", + "shasum": "" + }, + "require": { + "a9f/fractor": "^1.1", + "a9f/fractor-extension-installer": "^1.1", + "nette/utils": "^4.0", + "php": "^8.2", + "symplify/rule-doc-generator-contracts": "^11.2", + "tivie/htaccess-parser": "^0.4.0", + "webmozart/assert": "^1.11 || ^2.0" + }, + "type": "fractor-extension", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\FractorHtaccess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Simon Schaufelberger", + "email": "simonschaufi@gmail.com", + "role": "Lead Developer" + } + ], + "description": "htaccess extension for the File Read-Analyse-Change Tool. Allows modifying htaccess files", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "upgrade" + ], + "support": { + "source": "https://github.com/andreaswolf/fractor-htaccess/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:05+00:00" + }, + { + "name": "a9f/fractor-typoscript", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-typoscript.git", + "reference": "9cd1c20bd6fb22f97f0644eeaadc5a44cd45a5c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-typoscript/zipball/9cd1c20bd6fb22f97f0644eeaadc5a44cd45a5c9", + "reference": "9cd1c20bd6fb22f97f0644eeaadc5a44cd45a5c9", + "shasum": "" + }, + "require": { + "a9f/fractor": "^1.1", + "a9f/fractor-extension-installer": "^1.1", + "helmich/typo3-typoscript-parser": "^2.8.0", + "php": "^8.2", + "symplify/rule-doc-generator-contracts": "^11.2", + "webmozart/assert": "^1.11 || ^2.0" + }, + "type": "fractor-extension", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\FractorTypoScript\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Wolf", + "email": "dev@a-w.io", + "role": "Lead Developer" + } + ], + "description": "TypoScript extension for the File Read-Analyse-Change Tool. Allows modifying TYPO3 TypoScript configuration files", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "upgrade" + ], + "support": { + "source": "https://github.com/andreaswolf/fractor-typoscript/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:07+00:00" + }, + { + "name": "a9f/fractor-xliff", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-xliff.git", + "reference": "26ca6f4587a71eaf9e08ad630fa2ada77cd2daa5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-xliff/zipball/26ca6f4587a71eaf9e08ad630fa2ada77cd2daa5", + "reference": "26ca6f4587a71eaf9e08ad630fa2ada77cd2daa5", + "shasum": "" + }, + "require": { + "a9f/fractor": "^1.1", + "a9f/fractor-extension-installer": "^1.1", + "ext-dom": "*", + "ext-xml": "*", + "php": "^8.2", + "simonschaufi/pretty-xml": "^3.0.0", + "symplify/rule-doc-generator-contracts": "^11.2", + "webmozart/assert": "^1.11 || ^2.0" + }, + "type": "fractor-extension", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\FractorXliff\\": [ + "src/", + "rules/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "XLIFF extension for the File Read-Analyse-Change Tool. Allows modifying XLIFF translation files", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "translation", + "upgrade", + "xliff" + ], + "support": { + "source": "https://github.com/andreaswolf/fractor-xliff/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:05+00:00" + }, + { + "name": "a9f/fractor-xml", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-xml.git", + "reference": "ca4eda2ad1d3eee28e8c7614550ba983a4887eb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-xml/zipball/ca4eda2ad1d3eee28e8c7614550ba983a4887eb9", + "reference": "ca4eda2ad1d3eee28e8c7614550ba983a4887eb9", + "shasum": "" + }, + "require": { + "a9f/fractor": "^1.1", + "a9f/fractor-extension-installer": "^1.1", + "ext-dom": "*", + "ext-xml": "*", + "php": "^8.2", + "simonschaufi/pretty-xml": "^3.0.0", + "symplify/rule-doc-generator-contracts": "^11.2", + "webmozart/assert": "^1.11 || ^2.0" + }, + "type": "fractor-extension", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\FractorXml\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Wolf", + "email": "dev@a-w.io", + "role": "Lead Developer" + } + ], + "description": "XML extension for the File Read-Analyse-Change Tool. Allows modifying XML files", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "upgrade" + ], + "support": { + "source": "https://github.com/andreaswolf/fractor-xml/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:06+00:00" + }, + { + "name": "a9f/fractor-yaml", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-yaml.git", + "reference": "9f1f457abe6e7a6461e40cd45af79d0760b3d776" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-yaml/zipball/9f1f457abe6e7a6461e40cd45af79d0760b3d776", + "reference": "9f1f457abe6e7a6461e40cd45af79d0760b3d776", + "shasum": "" + }, + "require": { + "a9f/fractor": "^1.1", + "a9f/fractor-extension-installer": "^1.1", + "php": "^8.2", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symplify/rule-doc-generator-contracts": "^11.2", + "webmozart/assert": "^1.11 || ^2.0" + }, + "type": "fractor-extension", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\FractorYaml\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Wolf", + "email": "dev@a-w.io", + "role": "Lead Developer" + } + ], + "description": "YAML extension for the File Read-Analyse-Change Tool. Allows modifying YAML files", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "upgrade" + ], + "support": { + "source": "https://github.com/andreaswolf/fractor-yaml/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:05+00:00" + }, + { + "name": "a9f/typo3-fractor", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/andreaswolf/fractor-typo3-fractor.git", + "reference": "e5a5a5306df24e1f418ff9c2c2f3a12195f2f0fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreaswolf/fractor-typo3-fractor/zipball/e5a5a5306df24e1f418ff9c2c2f3a12195f2f0fb", + "reference": "e5a5a5306df24e1f418ff9c2c2f3a12195f2f0fb", + "shasum": "" + }, + "require": { + "a9f/fractor": "^1.1", + "a9f/fractor-extension-installer": "^1.1", + "a9f/fractor-fluid": "^1.1", + "a9f/fractor-htaccess": "^1.1", + "a9f/fractor-typoscript": "^1.1", + "a9f/fractor-xliff": "^1.1", + "a9f/fractor-xml": "^1.1", + "a9f/fractor-yaml": "^1.1", + "ext-dom": "*", + "php": "^8.2", + "symplify/rule-doc-generator-contracts": "^11.2" + }, + "bin": [ + "bin/typo3-fractor-init" + ], + "type": "fractor-extension", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "a9f\\Typo3Fractor\\": [ + "src/", + "rules/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Wolf", + "email": "dev@a-w.io", + "role": "Lead Developer" + } + ], + "description": "TYPO3 extension for the File Read-Analyse-Change Tool. Allows modifying all sorts of files TYPO3 understands", + "keywords": [ + "automation", + "dev", + "fractor", + "migration", + "refactoring", + "typo3", + "upgrade" + ], + "support": { + "source": "https://github.com/andreaswolf/fractor-typo3-fractor/tree/v1.1.0" + }, + "time": "2026-09-25T12:42:45+00:00", + "published-time": "2026-09-25T12:43:07+00:00" + }, + { + "name": "amphp/amp", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/73c38b323ff8d790abf0f76c56fcc892bda4111a", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-07-19T17:59:20+00:00", + "published-time": "2026-07-26T14:55:09+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "1511cdfdf868a706307358bd48f155c3df2c490f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/1511cdfdf868a706307358bd48f155c3df2c490f", + "reference": "1511cdfdf868a706307358bd48f155c3df2c490f", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-10-31T19:10:04+00:00", + "published-time": "2026-10-31T19:16:39+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00", + "published-time": "2024-04-19T03:40:51+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "2e2b4daa214a18b1528919d6781988ea65a9a47d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/2e2b4daa214a18b1528919d6781988ea65a9a47d", + "reference": "2e2b4daa214a18b1528919d6781988ea65a9a47d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-10-31T19:53:43+00:00", + "published-time": "2026-10-31T19:59:04+00:00" + }, + { + "name": "amphp/parallel", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/parallel.git", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/process": "^2", + "amphp/serialization": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], + "psr-4": { + "Amp\\Parallel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", + "keywords": [ + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" + ], + "support": { + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-16T16:54:01+00:00", + "published-time": "2026-05-16T17:04:58+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00", + "published-time": "2024-03-21T19:18:30+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.7" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-07-26T14:50:43+00:00", + "published-time": "2026-07-26T15:13:19+00:00" + }, + { + "name": "amphp/process", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "acfd64f68df5fa7f2d674c93d1986b2bf5d02770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/acfd64f68df5fa7f2d674c93d1986b2bf5d02770", + "reference": "acfd64f68df5fa7f2d674c93d1986b2bf5d02770", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-11-07T16:33:32+00:00", + "published-time": "2026-11-07T16:36:48+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00", + "published-time": "2026-04-05T16:03:16+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/b347be5aff6b2cc025208bb4d896607eb470c018", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-08-22T18:13:20+00:00", + "published-time": "2026-08-22T20:37:31+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "2f711b6651270449d59c770ffb518698655022f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/2f711b6651270449d59c770ffb518698655022f7", + "reference": "2f711b6651270449d59c770ffb518698655022f7", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-14T17:37:39+00:00", + "published-time": "2026-10-31T17:39:05+00:00" + }, + { + "name": "andersundsehr/grumphp-fractor-task", + "version": "11.2.2", + "source": { + "type": "git", + "url": "https://github.com/andersundsehr/grumphp-fractor-task.git", + "reference": "b73d38981b6a3f975897e8403db12e6f7a502830" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andersundsehr/grumphp-fractor-task/zipball/b73d38981b6a3f975897e8403db12e6f7a502830", + "reference": "b73d38981b6a3f975897e8403db12e6f7a502830", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpro/grumphp": "^2.0.0" + }, + "require-dev": { + "andersundsehr/phpstan-git-files": "@dev", + "andersundsehr/rector-p": "@dev", + "pluswerk/grumphp-bom-task": "@dev", + "pluswerk/grumphp-config": "@dev", + "pluswerk/grumphp-xliff-task": "@dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Andersundsehr\\GrumPHPFractorTask\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Benjamin Stiber", + "email": "b.stiber@andersundsehr.com", + "homepage": "https://www.andersundsehr.com" + } + ], + "description": "GrumPHP task for fractor", + "support": { + "issues": "https://github.com/andersundsehr/grumphp-config/issues", + "source": "https://github.com/andersundsehr/grumphp-config/tree/main/packages/grumphp-fractor-task" + }, + "time": "2026-11-06T06:19:37+00:00", + "published-time": "2026-11-06T10:40:56+00:00" + }, + { + "name": "andersundsehr/phpstan-git-files", + "version": "11.2.2", + "source": { + "type": "git", + "url": "https://github.com/andersundsehr/phpstan-git-files.git", + "reference": "925c954f8be8cb616902c425dfd0ec492531ae5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andersundsehr/phpstan-git-files/zipball/925c954f8be8cb616902c425dfd0ec492531ae5e", + "reference": "925c954f8be8cb616902c425dfd0ec492531ae5e", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.1.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpstan/phpstan": "^1.9 || ^2.0" + }, + "require-dev": { + "andersundsehr/grumphp-fractor-task": "@dev", + "andersundsehr/rector-p": "@dev", + "pluswerk/grumphp-bom-task": "@dev", + "pluswerk/grumphp-config": "@dev", + "pluswerk/grumphp-xliff-task": "@dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Matthias Vogel", + "email": "m.vogel@andersundsehr.com" + } + ], + "description": "Add all git files into phpstan config automatically", + "support": { + "issues": "https://github.com/andersundsehr/grumphp-config/issues", + "source": "https://github.com/andersundsehr/grumphp-config/tree/main/packages/phpstan-git-files" + }, + "time": "2026-11-05T21:30:36+00:00", + "published-time": "2026-11-06T10:40:57+00:00" + }, + { + "name": "andersundsehr/rector-p", + "version": "11.2.2", + "source": { + "type": "git", + "url": "https://github.com/andersundsehr/rector-p.git", + "reference": "f2907bb6d83a770e24ea9375cf0afd7614a45bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andersundsehr/rector-p/zipball/f2907bb6d83a770e24ea9375cf0afd7614a45bbf", + "reference": "f2907bb6d83a770e24ea9375cf0afd7614a45bbf", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "rector/rector": "^2.2.10", + "symfony/console": "^5.4 || ^6.4 || ^7.4 || ^8.1" + }, + "require-dev": { + "andersundsehr/grumphp-fractor-task": "@dev", + "andersundsehr/phpstan-git-files": "@dev", + "phpunit/phpunit": "^11.5.0 || ^12.0.0", + "pluswerk/grumphp-bom-task": "@dev", + "pluswerk/grumphp-config": "@dev", + "pluswerk/grumphp-xliff-task": "@dev" + }, + "bin": [ + "rector-p" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0-dev" + } + }, + "autoload": { + "psr-4": { + "Andersundsehr\\RectorP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Matthias Vogel", + "email": "m.vogel@andersundsehr.com" + } + ], + "description": "rector run file by file (like git add -p)", + "support": { + "issues": "https://github.com/andersundsehr/grumphp-config/issues", + "source": "https://github.com/andersundsehr/grumphp-config/tree/main/packages/rector-p" + }, + "time": "2026-11-06T10:34:28+00:00", + "published-time": "2026-11-06T10:40:56+00:00" + }, + { + "name": "bnf/phpstan-psr-container", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/bnf/phpstan-psr-container.git", + "reference": "2e2fd1973576bdc755ea814b2142fcdbc673fd1c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bnf/phpstan-psr-container/zipball/2e2fd1973576bdc755ea814b2142fcdbc673fd1c", + "reference": "2e2fd1973576bdc755ea814b2142fcdbc673fd1c", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0", + "phpstan/phpstan": "^1.0|^2.0", + "psr/container": "^1.0|^2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Bnf\\PhpstanPsrContainer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Benjamin Franzke", + "email": "benjaminfranzke@gmail.com", + "homepage": "https://bnfr.net", + "role": "Developer" + } + ], + "description": "PHPStan dynamic return type extension for PSR-11 ContainerInterface", + "keywords": [ + "PHPStan", + "PSR-11", + "service-provider", + "static" + ], + "support": { + "issues": "https://github.com/bnf/phpstan-psr-container/issues", + "source": "https://github.com/bnf/phpstan-psr-container/tree/1.1.0" + }, + "time": "2024-11-19T16:16:59+00:00", + "published-time": "2024-11-19T16:19:17+00:00" + }, + { + "name": "composer/semver", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "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": "Version comparison 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.6.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-29T09:13:34+00:00", + "published-time": "2026-10-29T09:14:11+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00", + "published-time": "2024-04-13T16:58:48+00:00" + }, + { + "name": "doctrine/collections", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7c2f25ed928553a3de389ccb99aa46c0eedb2d4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7c2f25ed928553a3de389ccb99aa46c0eedb2d4d", + "reference": "7c2f25ed928553a3de389ccb99aa46c0eedb2d4d", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.4", + "symfony/polyfill-php86": "^1.36" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "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": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/3.1.0" + }, + "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%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-04-29T20:41:02+00:00", + "published-time": "2026-04-29T20:41:48+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "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.6" + }, + "time": "2026-02-07T07:09:04+00:00", + "published-time": "2026-02-07T07:10:09+00:00" + }, + { + "name": "ergebnis/composer-normalize", + "version": "2.56.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/composer-normalize.git", + "reference": "36df55b10d751ebdece4f22b7b74f5f6f72b3248" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/36df55b10d751ebdece4f22b7b74f5f6f72b3248", + "reference": "36df55b10d751ebdece4f22b7b74f5f6f72b3248", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.0.0", + "ergebnis/json": "^1.4.0", + "ergebnis/json-normalizer": "^4.12.0", + "ergebnis/json-printer": "^3.7.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "localheinz/diff": "^1.3.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "composer/composer": "^2.11.0", + "ergebnis/license": "^2.8.0", + "ergebnis/php-cs-fixer-config": "^6.64.1", + "ergebnis/phpstan-rules": "^2.14.2", + "ergebnis/phpunit-slow-test-detector": "^2.27.0", + "ergebnis/rector-rules": "^1.18.5", + "fakerphp/faker": "^1.24.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.55", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.1.1", + "phpstan/phpstan-strict-rules": "^2.1.0", + "phpunit/phpunit": "^9.6.33", + "rector/rector": "^2.7.0", + "symfony/filesystem": "^5.4.41" + }, + "type": "composer-plugin", + "extra": { + "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", + "branch-alias": { + "dev-main": "2.56-dev" + }, + "plugin-optional": true, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Composer\\Normalize\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a composer plugin for normalizing composer.json.", + "homepage": "https://github.com/ergebnis/composer-normalize", + "keywords": [ + "composer", + "normalize", + "normalizer", + "plugin" + ], + "support": { + "issues": "https://github.com/ergebnis/composer-normalize/issues", + "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/composer-normalize" + }, + "time": "2026-10-29T15:47:39+00:00", + "published-time": "2026-10-29T15:47:48+00:00" + }, + { + "name": "ergebnis/json", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json.git", + "reference": "829ce8b25c5e452249492c824f1d227777709403" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json/zipball/829ce8b25c5e452249492c824f1d227777709403", + "reference": "829ce8b25c5e452249492c824f1d227777709403", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.63.3", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.25.0", + "ergebnis/rector-rules": "^1.18.4", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.2.14", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.0.18", + "phpstan/phpstan-strict-rules": "^2.0.12", + "phpunit/phpunit": "^9.6.24", + "rector/rector": "^2.6.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.7-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a Json value object for representing a valid JSON string.", + "homepage": "https://github.com/ergebnis/json", + "keywords": [ + "json" + ], + "support": { + "issues": "https://github.com/ergebnis/json/issues", + "security": "https://github.com/ergebnis/json/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json" + }, + "time": "2026-09-24T19:42:16+00:00", + "published-time": "2026-09-24T19:42:28+00:00" + }, + { + "name": "ergebnis/json-normalizer", + "version": "4.12.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-normalizer.git", + "reference": "b1a23409760cbac5b4deef9284c4e5e4a9c9165b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/b1a23409760cbac5b4deef9284c4e5e4a9c9165b", + "reference": "b1a23409760cbac5b4deef9284c4e5e4a9c9165b", + "shasum": "" + }, + "require": { + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ergebnis/json-printer": "^3.5.0", + "ergebnis/json-schema-validator": "^4.2.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "ergebnis/data-provider": "^3.7.1", + "ergebnis/license": "^2.8.0", + "ergebnis/php-cs-fixer-config": "^6.64.1", + "ergebnis/phpunit-slow-test-detector": "^2.27.0", + "ergebnis/rector-rules": "^1.18.5", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.2.15", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.1.1", + "phpstan/phpstan-strict-rules": "^2.1.0", + "phpunit/phpunit": "^9.6.19", + "rector/rector": "^2.7.0" + }, + "suggest": { + "composer/semver": "If you want to use ComposerJsonNormalizer or VersionConstraintNormalizer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.12-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Normalizer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides generic and vendor-specific normalizers for normalizing JSON documents.", + "homepage": "https://github.com/ergebnis/json-normalizer", + "keywords": [ + "json", + "normalizer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-normalizer/issues", + "security": "https://github.com/ergebnis/json-normalizer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-normalizer" + }, + "time": "2026-10-29T15:38:49+00:00", + "published-time": "2026-10-29T15:38:58+00:00" + }, + { + "name": "ergebnis/json-pointer", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-pointer.git", + "reference": "414a90039cdf458eeb71297315a2be6de195697a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/414a90039cdf458eeb71297315a2be6de195697a", + "reference": "414a90039cdf458eeb71297315a2be6de195697a", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.63.3", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.25.0", + "ergebnis/rector-rules": "^1.18.3", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.2.14", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.0.18", + "phpstan/phpstan-strict-rules": "^2.0.12", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.6.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.9-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Pointer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides an abstraction of a JSON pointer.", + "homepage": "https://github.com/ergebnis/json-pointer", + "keywords": [ + "RFC6901", + "json", + "pointer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-pointer/issues", + "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-pointer" + }, + "time": "2026-09-24T19:42:34+00:00", + "published-time": "2026-09-24T19:42:46+00:00" + }, + { + "name": "ergebnis/json-printer", + "version": "3.9.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-printer.git", + "reference": "a9bbf3816ca39382d6fb4b4ad58018e5ea4f3cb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/a9bbf3816ca39382d6fb4b4ad58018e5ea4f3cb4", + "reference": "a9bbf3816ca39382d6fb4b4ad58018e5ea4f3cb4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/data-provider": "^3.7.0", + "ergebnis/license": "^2.8.0", + "ergebnis/php-cs-fixer-config": "^6.64.1", + "ergebnis/phpunit-slow-test-detector": "^2.26.0", + "ergebnis/rector-rules": "^1.18.5", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.2.16", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.1.1", + "phpstan/phpstan-strict-rules": "^2.1.0", + "phpunit/phpunit": "^9.6.21", + "rector/rector": "^2.7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.9-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Printer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON printer, allowing for flexible indentation.", + "homepage": "https://github.com/ergebnis/json-printer", + "keywords": [ + "formatter", + "json", + "printer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-printer/issues", + "security": "https://github.com/ergebnis/json-printer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-printer" + }, + "time": "2026-10-21T19:02:11+00:00", + "published-time": "2026-10-21T19:02:23+00:00" + }, + { + "name": "ergebnis/json-schema-validator", + "version": "4.6.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-schema-validator.git", + "reference": "228bf86d963c06c863a3143c9d8531bb3ef72fbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/228bf86d963c06c863a3143c9d8531bb3ef72fbe", + "reference": "228bf86d963c06c863a3143c9d8531bb3ef72fbe", + "shasum": "" + }, + "require": { + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.63.3", + "ergebnis/phpunit-slow-test-detector": "^2.25.0", + "ergebnis/rector-rules": "^1.18.4", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.2.15", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.0.18", + "phpstan/phpstan-strict-rules": "^2.0.12", + "phpunit/phpunit": "^9.6.20", + "rector/rector": "^2.6.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.6-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\SchemaValidator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON schema validator, building on top of justinrainbow/json-schema.", + "homepage": "https://github.com/ergebnis/json-schema-validator", + "keywords": [ + "json", + "schema", + "validator" + ], + "support": { + "issues": "https://github.com/ergebnis/json-schema-validator/issues", + "security": "https://github.com/ergebnis/json-schema-validator/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-schema-validator" + }, + "time": "2026-09-24T19:42:52+00:00", + "published-time": "2026-09-24T19:43:04+00:00" + }, + { + "name": "gitonomy/gitlib", + "version": "v1.7.1", + "source": { + "type": "git", + "url": "https://github.com/gitonomy/gitlib.git", + "reference": "6da07267e07b240616ec8a016e1614eeb3a1016c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gitonomy/gitlib/zipball/6da07267e07b240616ec8a016e1614eeb3a1016c", + "reference": "6da07267e07b240616ec8a016e1614eeb3a1016c", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "php": "^8.1", + "symfony/deprecation-contracts": "^2.5 || ^3", + "symfony/polyfill-mbstring": "^1.7", + "symfony/process": "^5.4.51 || ^6.4.33 || ~7.3.11 || ^7.4.5 || ^8.0.5" + }, + "require-dev": { + "ext-fileinfo": "*", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^7.5.20 || ^8.5.20 || ^9.5.9", + "psr/log": "^1.0", + "symfony/phpunit-bridge": "^6.4 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gitonomy\\Git\\": "src/Gitonomy/Git/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Julien Didier", + "email": "genzo.wm@gmail.com", + "homepage": "https://github.com/juliendidier" + }, + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info", + "homepage": "https://github.com/lyrixx" + }, + { + "name": "Alexandre Salomé", + "email": "alexandre.salome@gmail.com", + "homepage": "https://github.com/alexandresalome" + } + ], + "description": "Library for accessing git", + "support": { + "issues": "https://github.com/gitonomy/gitlib/issues", + "source": "https://github.com/gitonomy/gitlib/tree/v1.7.1" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/gitonomy/gitlib", + "type": "tidelift" + } + ], + "time": "2026-11-07T20:55:23+00:00", + "published-time": "2026-11-07T20:55:28+00:00" + }, + { + "name": "helmich/typo3-typoscript-parser", + "version": "v2.8.1", + "source": { + "type": "git", + "url": "https://github.com/martin-helmich/typo3-typoscript-parser.git", + "reference": "dd9f7a3acbd240670e168619d308df603d4b56d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/martin-helmich/typo3-typoscript-parser/zipball/dd9f7a3acbd240670e168619d308df603d4b56d6", + "reference": "dd9f7a3acbd240670e168619d308df603d4b56d6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "php-vfs/php-vfs": "^1.4.2", + "phpspec/prophecy-phpunit": "^2.1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5 || ^11.5", + "symfony/phpunit-bridge": "^5.4 || ^6.4 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Helmich\\TypoScriptParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Martin Helmich", + "email": "m.helmich@mittwald.de", + "role": "lead" + } + ], + "description": "Parser for the TYPO3 configuration language TypoScript.", + "homepage": "https://github.com/martin-helmich", + "support": { + "issues": "https://github.com/martin-helmich/typo3-typoscript-parser/issues", + "source": "https://github.com/martin-helmich/typo3-typoscript-parser/tree/v2.8.1" + }, + "funding": [ + { + "url": "https://donate.helmich.me", + "type": "custom" + }, + { + "url": "https://github.com/martin-helmich", + "type": "github" + } + ], + "time": "2025-08-31T09:10:27+00:00", + "published-time": "2025-08-31T09:17:45+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.14.0", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "bae415e135dd68260c2d020ba392850231e9b1ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bae415e135dd68260c2d020ba392850231e9b1ab", + "reference": "bae415e135dd68260c2d020ba392850231e9b1ab", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "dev-main", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Danny van der Sluijs", + "email": "danny.vandersluijs@icloud.com", + "role": "Maintainer" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.14.0" + }, + "time": "2026-10-24T08:53:34+00:00", + "published-time": "2026-10-24T08:53:38+00:00" + }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00", + "published-time": "2023-02-03T21:33:41+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.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": "2026-09-22T14:32:34+00:00", + "published-time": "2026-09-22T14:33:13+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "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|^2", + "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|^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.36.0" + }, + "time": "2026-09-02T08:00:27+00:00", + "published-time": "2026-09-02T08:01:40+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "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.35.3" + }, + "time": "2026-08-12T13:29:21+00:00", + "published-time": "2026-08-22T12:57:18+00:00" + }, + { + "name": "league/flysystem-memory", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-memory.git", + "reference": "b2d1700ed1215684e7276e55bcacf350e0e06ff9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-memory/zipball/b2d1700ed1215684e7276e55bcacf350e0e06ff9", + "reference": "b2d1700ed1215684e7276e55bcacf350e0e06ff9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\InMemory\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "In-memory filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "memory" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-memory/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00", + "published-time": "2026-01-23T15:44:36+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "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 || ^11.0 || ^12.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.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00", + "published-time": "2026-07-09T11:50:09+00:00" + }, + { + "name": "league/uri", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "be89e2382e37acdef232259071ade5180821397b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/be89e2382e37acdef232259071ade5180821397b", + "reference": "be89e2382e37acdef232259071ade5180821397b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/uri-interfaces": "^7.10.0", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-11-04T12:45:07+00:00", + "published-time": "2026-11-04T13:02:21+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "ea66240cff4ae9a56ce85d577a04d038b771e51c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ea66240cff4ae9a56ce85d577a04d038b771e51c", + "reference": "ea66240cff4ae9a56ce85d577a04d038b771e51c", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-11-04T12:45:07+00:00", + "published-time": "2026-11-04T13:02:27+00:00" + }, + { + "name": "localheinz/diff", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/localheinz/diff.git", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/localheinz/diff/zipball/33bd840935970cda6691c23fc7d94ae764c0734c", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c", + "shasum": "" + }, + "require": { + "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5.0 || ^8.5.23", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "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": "Fork of sebastian/diff for use with ergebnis/composer-normalize", + "homepage": "https://github.com/localheinz/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/localheinz/diff/issues", + "source": "https://github.com/localheinz/diff/tree/1.3.0" + }, + "time": "2025-08-30T09:44:18+00:00", + "published-time": "2025-08-30T09:45:38+00:00" + }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00", + "published-time": "2025-09-14T11:32:17+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", + "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 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "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", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", + "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.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-29T19:21:12+00:00", + "published-time": "2026-09-29T19:21:44+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^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.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00", + "published-time": "2026-08-11T10:29:20+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.6", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "6bf24518accf32cf845bca305e0b92078700fd01" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/6bf24518accf32cf845bca305e0b92078700fd01", + "reference": "6bf24518accf32cf845bca305e0b92078700fd01", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.6" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() 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.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "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.1.6" + }, + "time": "2026-10-20T17:07:56+00:00", + "published-time": "2026-10-20T17:48:30+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "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.x-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.9.0" + }, + "time": "2026-09-13T18:51:52+00:00", + "published-time": "2026-09-13T18:53:16+00:00" + }, + { + "name": "ondram/ci-detector", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/OndraM/ci-detector.git", + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.13.2", + "lmc/coding-standard": "^3.0.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.1.0", + "phpstan/phpstan": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpunit/phpunit": "^9.6.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "OndraM\\CiDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Machulda", + "email": "ondrej.machulda@gmail.com" + } + ], + "description": "Detect continuous integration environment and provide unified access to properties of current build", + "keywords": [ + "CircleCI", + "Codeship", + "Wercker", + "adapter", + "appveyor", + "aws", + "aws codebuild", + "azure", + "azure devops", + "azure pipelines", + "bamboo", + "bitbucket", + "buddy", + "ci-info", + "codebuild", + "continuous integration", + "continuousphp", + "devops", + "drone", + "github", + "gitlab", + "interface", + "jenkins", + "pipelines", + "sourcehut", + "teamcity", + "travis" + ], + "support": { + "issues": "https://github.com/OndraM/ci-detector/issues", + "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" + }, + "time": "2024-03-12T13:22:30+00:00", + "published-time": "2024-03-12T13:23:38+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", + "published-time": "2024-03-03T12:39:44+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", + "published-time": "2022-02-21T01:04:30+00:00" + }, + { + "name": "php-parallel-lint/php-parallel-lint", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-parallel-lint/PHP-Parallel-Lint.git", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-parallel-lint/PHP-Parallel-Lint/zipball/6db563514f27e19595a19f45a4bf757b6401194e", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3.0" + }, + "replace": { + "grogy/php-parallel-lint": "*", + "jakub-onderka/php-parallel-lint": "*" + }, + "require-dev": { + "nette/tester": "^1.3 || ^2.0", + "php-parallel-lint/php-console-highlighter": "0.* || ^1.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "suggest": { + "php-parallel-lint/php-console-highlighter": "Highlight syntax in code snippet" + }, + "bin": [ + "parallel-lint" + ], + "type": "library", + "autoload": { + "classmap": [ + "./src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "ahoj@jakubonderka.cz" + } + ], + "description": "This tool checks the syntax of PHP files about 20x faster than serial check.", + "homepage": "https://github.com/php-parallel-lint/PHP-Parallel-Lint", + "keywords": [ + "lint", + "static analysis" + ], + "support": { + "issues": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/issues", + "source": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/tree/v1.4.0" + }, + "time": "2024-03-27T12:14:49+00:00", + "published-time": "2024-03-27T12:18:41+00:00" + }, + { + "name": "phpro/grumphp", + "version": "v2.26.0", + "source": { + "type": "git", + "url": "https://github.com/phpro/grumphp.git", + "reference": "c469dc05dcb81d6893bda31ff87eb48512c30fc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpro/grumphp/zipball/c469dc05dcb81d6893bda31ff87eb48512c30fc7", + "reference": "c469dc05dcb81d6893bda31ff87eb48512c30fc7", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.0", + "amphp/parallel": "^2.1", + "composer-plugin-api": "^2.0", + "doctrine/collections": "^1.6.8 || ^2.0 || ^3.0", + "ext-json": "*", + "gitonomy/gitlib": "^1.7", + "laravel/serializable-closure": "^2.0", + "monolog/monolog": "^2.0 || ^3.0", + "ondram/ci-detector": "^4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.1 || ^2.0", + "seld/jsonlint": "^1.8", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/dotenv": "^6.4 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", + "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/options-resolver": "^6.4 || ^7.0 || ^8.0", + "symfony/process": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "composer/composer": "^2.9.6", + "nikic/php-parser": "^5.7", + "php-cs-fixer/shim": "^3.93", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/phpspec": "^8.2", + "phpspec/prophecy": "^1.24", + "phpspec/prophecy-phpunit": "^2.4", + "phpunit/phpunit": "^11.5.50" + }, + "suggest": { + "atoum/atoum": "Lets GrumPHP run your unit tests.", + "behat/behat": "Lets GrumPHP validate your project features.", + "brianium/paratest": "Lets GrumPHP run PHPUnit in parallel.", + "carthage-software/mago": "Lets GrumPHP help you write better PHP code.", + "codeception/codeception": "Lets GrumPHP run your project's full stack tests", + "consolidation/robo": "Lets GrumPHP run your automated PHP tasks.", + "designsecurity/progpilot": "Lets GrumPHP be sure that there are no vulnerabilities in your code.", + "doctrine/orm": "Lets GrumPHP validate your Doctrine mapping files.", + "enlightn/security-checker": "Lets GrumPHP be sure that there are no known security issues.", + "ergebnis/composer-normalize": "Lets GrumPHP tidy and normalize your composer.json file.", + "friendsofphp/php-cs-fixer": "Lets GrumPHP automatically fix your codestyle.", + "friendsoftwig/twigcs": "Lets GrumPHP check Twig coding standard.", + "infection/infection": "Lets GrumPHP evaluate the quality your unit tests", + "maglnet/composer-require-checker": "Lets GrumPHP analyze composer dependencies.", + "malukenho/kawaii-gherkin": "Lets GrumPHP lint your Gherkin files.", + "nette/tester": "Lets GrumPHP run your unit tests with nette tester.", + "nikic/php-parser": "Lets GrumPHP run static analyses through your PHP files.", + "pestphp/pest": "Lets GrumPHP run your unit test with Pest PHP", + "phan/phan": "Lets GrumPHP unleash a static analyzer on your code", + "phing/phing": "Lets GrumPHP run your automated PHP tasks.", + "php-parallel-lint/php-parallel-lint": "Lets GrumPHP quickly lint your entire code base.", + "phparkitect/phparkitect": "Let GrumPHP keep your codebase coherent and solid, by permitting to add some architectural constraint check to your workflow.", + "phpmd/phpmd": "Lets GrumPHP sort out the mess in your code", + "phpspec/phpspec": "Lets GrumPHP spec your code.", + "phpstan/phpstan": "Lets GrumPHP discover bugs in your code without running it.", + "phpunit/phpunit": "Lets GrumPHP run your unit tests.", + "povils/phpmnd": "Lets GrumPHP help you detect magic numbers in PHP code.", + "rector/rector ": "Lets GrumPHP instantly upgrade and automatically refactor your PHP code.", + "roave/security-advisories": "Lets GrumPHP be sure that there are no known security issues.", + "sebastian/phpcpd": "Lets GrumPHP find duplicated code.", + "squizlabs/php_codesniffer": "Lets GrumPHP sniff on your code.", + "sstalle/php7cc": "Lets GrumPHP check PHP 5.3 - 5.6 code compatibility with PHP 7.", + "symfony/phpunit-bridge": "Lets GrumPHP run your unit tests with the phpunit-bridge of Symfony.", + "symplify/easy-coding-standard": "Lets GrumPHP check coding standard.", + "vimeo/psalm": "Lets GrumPHP discover errors in your code without running it.", + "vincentlanglet/twig-cs-fixer": "Lets GrumPHP check and fix twig coding standard." + }, + "bin": [ + "bin/grumphp" + ], + "type": "composer-plugin", + "extra": { + "class": "GrumPHP\\Composer\\GrumPHPPlugin" + }, + "autoload": { + "psr-4": { + "GrumPHP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Toon Verwerft", + "email": "toon.verwerft@phpro.be" + }, + { + "name": "Community", + "homepage": "https://github.com/phpro/grumphp/graphs/contributors" + } + ], + "description": "A composer plugin that enables source code quality checks.", + "support": { + "issues": "https://github.com/phpro/grumphp/issues", + "source": "https://github.com/phpro/grumphp/tree/v2.26.0" + }, + "time": "2026-10-16T13:19:51+00:00", + "published-time": "2026-10-16T13:20:29+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", + "published-time": "2024-09-04T20:22:27+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.3.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/645a1bd6bead15fc1b8474137467961d0fa30a79", + "reference": "645a1bd6bead15fc1b8474137467961d0fa30a79", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "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": "2026-11-03T12:14:22+00:00", + "published-time": "2026-11-03T12:14:36+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7440368d94738d54f2c1de24dbd3871793bbb0dd", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.9.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0.1", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "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": "12.5.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/12.5.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-10-03T14:03:50+00:00", + "published-time": "2026-10-03T14:04:33+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "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": "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/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00", + "published-time": "2026-08-25T14:41:57+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00", + "published-time": "2025-02-07T04:59:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "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", + "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/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-10-03T04:51:29+00:00", + "published-time": "2026-10-03T04:54:39+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00", + "published-time": "2025-02-07T04:59:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.43", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "1d5a4a3c98fa1ca3a51402c74b66c2c8df85e0bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1d5a4a3c98fa1ca3a51402c74b66c2c8df85e0bb", + "reference": "1d5a4a3c98fa1ca3a51402c74b66c2c8df85e0bb", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.8", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.1", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.1", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.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/12.5.43" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-11-02T07:10:00+00:00", + "published-time": "2026-11-02T07:10:59+00:00" + }, + { + "name": "pluswerk/grumphp-bom-task", + "version": "11.2.2", + "source": { + "type": "git", + "url": "https://github.com/andersundsehr/grumphp-bom-task.git", + "reference": "130fd2bb13d5ae75076be615d63c93387e49a05d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andersundsehr/grumphp-bom-task/zipball/130fd2bb13d5ae75076be615d63c93387e49a05d", + "reference": "130fd2bb13d5ae75076be615d63c93387e49a05d", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpro/grumphp": "^2.0.0" + }, + "require-dev": { + "andersundsehr/grumphp-fractor-task": "@dev", + "andersundsehr/phpstan-git-files": "@dev", + "andersundsehr/rector-p": "@dev", + "pluswerk/grumphp-config": "@dev", + "pluswerk/grumphp-xliff-task": "@dev" + }, + "bin": [ + "bin/fixbom" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "PLUS\\GrumPHPBomTask\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Matthias Vogel", + "email": "m.vogel@andersundsehr.com", + "homepage": "https://www.andersundsehr.com" + } + ], + "description": "Force files to have no BOM", + "support": { + "issues": "https://github.com/andersundsehr/grumphp-config/issues", + "source": "https://github.com/andersundsehr/grumphp-config/tree/main/packages/grumphp-bom-task" + }, + "time": "2026-11-05T21:30:36+00:00", + "published-time": "2026-11-06T10:40:56+00:00" + }, + { + "name": "pluswerk/grumphp-config", + "version": "11.2.2", + "source": { + "type": "git", + "url": "https://github.com/andersundsehr/grumphp-config.git", + "reference": "93d22411ab5ded827ff6fc20a84df54636f09d21" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andersundsehr/grumphp-config/zipball/93d22411ab5ded827ff6fc20a84df54636f09d21", + "reference": "93d22411ab5ded827ff6fc20a84df54636f09d21", + "shasum": "" + }, + "require": { + "a9f/typo3-fractor": "^0.5.8 || ^1.0.0", + "andersundsehr/grumphp-fractor-task": "^11.0.0", + "andersundsehr/phpstan-git-files": "^11.0.0", + "andersundsehr/rector-p": "^11.0.0", + "bnf/phpstan-psr-container": "^1.1.0", + "composer-plugin-api": "^2.1.0", + "composer/semver": "^3.4.3", + "ergebnis/composer-normalize": "^2.45.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpro/grumphp": "^2.16.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.5", + "pluswerk/grumphp-bom-task": "^11.0.0", + "pluswerk/grumphp-xliff-task": "^11.0.0", + "rector/rector": "^2.2.10", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "squizlabs/php_codesniffer": "^4.0.1", + "symfony/yaml": "^5.4.0 || ^6.4.18 || ^7.4.0 || ^8.1.0" + }, + "require-dev": { + "andersundsehr/grumphp-fractor-task": "dev-main as 11.0.0", + "andersundsehr/phpstan-git-files": "dev-main as 11.0.0", + "andersundsehr/rector-p": "dev-main as 11.0.0", + "composer/composer": "^2.8.5", + "phpunit/phpunit": "^11.5.0 || ^12.0.0", + "pluswerk/grumphp-bom-task": "dev-main as 11.0.0", + "pluswerk/grumphp-xliff-task": "dev-main as 11.0.0", + "roave/security-advisories": "dev-latest", + "symfony/var-dumper": "^5.4.0 || ^6.4.18 || ^7.0.0 || ^8.1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PLUS\\GrumPHPConfig\\Composer\\Plugin" + }, + "autoload": { + "psr-4": { + "PLUS\\GrumPHPConfig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Matthias Vogel", + "email": "m.vogel@andersundsehr.com", + "homepage": "https://www.andersundsehr.com" + } + ], + "description": "GrumPHP config for php projects(mainly TYPO3)", + "keywords": [ + "dev" + ], + "support": { + "issues": "https://github.com/andersundsehr/grumphp-config/issues", + "source": "https://github.com/andersundsehr/grumphp-config/tree/11.2.2" + }, + "time": "2026-11-06T10:33:17+00:00", + "published-time": "2026-11-06T10:39:38+00:00" + }, + { + "name": "pluswerk/grumphp-xliff-task", + "version": "11.2.2", + "source": { + "type": "git", + "url": "https://github.com/andersundsehr/grumphp-xliff-task.git", + "reference": "360a17d206cb64dbe9c9355ca30451708b91105f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andersundsehr/grumphp-xliff-task/zipball/360a17d206cb64dbe9c9355ca30451708b91105f", + "reference": "360a17d206cb64dbe9c9355ca30451708b91105f", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpro/grumphp": "^2.0.0" + }, + "require-dev": { + "andersundsehr/grumphp-fractor-task": "@dev", + "andersundsehr/phpstan-git-files": "@dev", + "andersundsehr/rector-p": "@dev", + "pluswerk/grumphp-bom-task": "@dev", + "pluswerk/grumphp-config": "@dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "PLUS\\GrumPHPXliffTask\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Matthias Vogel", + "email": "m.vogel@andersundsehr.com", + "homepage": "https://www.andersundsehr.com" + } + ], + "description": "GrumPHP task to lint xlf/xliff Files", + "support": { + "issues": "https://github.com/andersundsehr/grumphp-config/issues", + "source": "https://github.com/andersundsehr/grumphp-config/tree/main/packages/grumphp-xliff-task" + }, + "time": "2026-11-05T21:30:37+00:00", + "published-time": "2026-11-06T10:40:55+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00", + "published-time": "2021-02-03T23:27:34+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", + "published-time": "2019-03-25T15:31:44+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", + "published-time": "2024-05-10T07:32:19+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", + "published-time": "2023-04-04T09:55:35+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", + "published-time": "2024-09-11T13:21:14+00:00" + }, + { + "name": "rector/rector", + "version": "2.8.4", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "55acbd956d7e3907f7d21aab54edd68a310609e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/55acbd956d7e3907f7d21aab54edd68a310609e6", + "reference": "55acbd956d7e3907f7d21aab54edd68a310609e6", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.3.5" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "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", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.8.4" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-11-03T18:48:08+00:00", + "published-time": "2026-11-03T18:48:15+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00", + "published-time": "2026-05-16T18:04:08+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.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 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/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00", + "published-time": "2026-05-17T05:30:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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": "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/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00", + "published-time": "2026-05-21T04:45:36+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.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", + "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/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00", + "published-time": "2025-02-07T04:55:36+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-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/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00", + "published-time": "2026-08-25T15:37:16+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00", + "published-time": "2026-05-25T13:40:39+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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": "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/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00", + "published-time": "2026-05-20T04:37:31+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00", + "published-time": "2026-06-01T15:20:45+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "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 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/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00", + "published-time": "2026-05-19T16:22:47+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00", + "published-time": "2025-02-07T04:58:08+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.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": "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", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00", + "published-time": "2025-02-07T04:58:28+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00", + "published-time": "2025-08-13T04:45:10+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "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": "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", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00", + "published-time": "2026-05-20T06:50:13+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "a8b6643a19ba6c8125a473623e45fb8cbb0e4159" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/a8b6643a19ba6c8125a473623e45fb8cbb0e4159", + "reference": "a8b6643a19ba6c8125a473623e45fb8cbb0e4159", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "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": "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", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-11-09T06:30:55+00:00", + "published-time": "2026-11-09T06:31:20+00:00" + }, + { + "name": "seld/jsonlint", + "version": "1.12.2", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "4882a0459c88772e4afa3a73ee81b7824490aa9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/4882a0459c88772e4afa3a73ee81b7824490aa9c", + "reference": "4882a0459c88772e4afa3a73ee81b7824490aa9c", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" + }, + "bin": [ + "bin/jsonlint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "support": { + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.12.2" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2026-11-03T10:16:18+00:00", + "published-time": "2026-11-03T10:20:37+00:00" + }, + { + "name": "simonschaufi/pretty-xml", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/simonschaufi/pretty-xml.git", + "reference": "8760267bcf603882c6cb8e384f885d7b664c5a9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simonschaufi/pretty-xml/zipball/8760267bcf603882c6cb8e384f885d7b664c5a9a", + "reference": "8760267bcf603882c6cb8e384f885d7b664c5a9a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.31" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.47", + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "PrettyXml\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Shane Auckland", + "email": "shane.auckland@gmail.com", + "homepage": "https://shaneauckland.co.uk" + } + ], + "description": "Library for pretty-printing XML", + "keywords": [ + "pretty", + "xml" + ], + "support": { + "issues": "https://github.com/simonschaufi/pretty-xml/issues", + "source": "https://github.com/simonschaufi/pretty-xml/tree/3.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/simonschaufi/10", + "type": "custom" + }, + { + "url": "https://github.com/simonschaufi", + "type": "github" + } + ], + "time": "2026-09-25T10:02:38+00:00", + "published-time": "2026-09-25T12:28:14+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96", + "shasum": "" + }, + "require": { + "ext-libxml": "*", + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + }, + "suggest": { + "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.", + "ext-pcntl": "For parallel processing support via the --parallel CLI option." + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T02:45:27+00:00", + "published-time": "2026-08-06T02:57:35+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00", + "published-time": "2024-10-20T05:09:39+00:00" + }, + { + "name": "symfony/cache", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "3e9c898a1c2d78661676befcc2f9dd610c56c9dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/3e9c898a1c2d78661676befcc2f9dd610c56c9dd", + "reference": "3e9c898a1c2d78661676befcc2f9dd610c56c9dd", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^7.4|^8.0" + }, + "conflict": { + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "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 extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-29T04:09:38+00:00", + "published-time": "2026-07-29T11:49:00+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "af26a91232085b7edd4e04be62f75e7c25159ece" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/af26a91232085b7edd4e04be62f75e7c25159ece", + "reference": "af26a91232085b7edd4e04be62f75e7c25159ece", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "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 caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T07:05:14+00:00", + "published-time": "2026-11-04T11:25:38+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/8ef666abf731e85e59cf478929dda6c7cb2fa9c4", + "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "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 you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:27:33+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.3.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "1d5596f8a5da5c27539634cd6ba769f107991234" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/1d5596f8a5da5c27539634cd6ba769f107991234", + "reference": "1d5596f8a5da5c27539634cd6ba769f107991234", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.5", + "symfony/var-exporter": "^6.4.20|^7.2.5" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "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 you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:00:40+00:00", + "published-time": "2026-01-28T10:29:29+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "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": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-26T10:31:34+00:00", + "published-time": "2026-08-30T21:43:37+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.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": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.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/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00", + "published-time": "2026-08-22T13:46:58+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "658e29751424da3ef4758c60f769e59d1b2981eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/658e29751424da3ef4758c60f769e59d1b2981eb", + "reference": "658e29751424da3ef4758c60f769e59d1b2981eb", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "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.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:36+00:00" + }, + { + "name": "symfony/expression-language", + "version": "v8.1.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "9c2729bca940d84938dc0b435d037e4844f237c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/9c2729bca940d84938dc0b435d037e4844f237c9", + "reference": "9c2729bca940d84938dc0b435d037e4844f237c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/cache": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "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 an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/expression-language/tree/v8.1.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-09T13:58:01+00:00", + "published-time": "2026-10-14T06:48:30+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "58f57b6a33e3495f7f41e01258f1ae5ef76cccd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/58f57b6a33e3495f7f41e01258f1ae5ef76cccd2", + "reference": "58f57b6a33e3495f7f41e01258f1ae5ef76cccd2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "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 basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:39:20+00:00", + "published-time": "2026-11-04T11:32:01+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.1.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "a2902e479ba5691d20d51d100a715ea19ad2c837" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/a2902e479ba5691d20d51d100a715ea19ad2c837", + "reference": "a2902e479ba5691d20d51d100a715ea19ad2c837", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.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/v8.1.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-02T10:58:30+00:00", + "published-time": "2026-11-04T11:31:20+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.1.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "68cc43038d234c69d510c62f37bd0058b40d548a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/68cc43038d234c69d510c62f37bd0058b40d548a", + "reference": "68cc43038d234c69d510c62f37bd0058b40d548a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "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 an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.1.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-18T14:56:39+00:00", + "published-time": "2026-10-28T18:24:07+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "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.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00", + "published-time": "2026-09-30T10:23:35+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/8840df45b24f33a49dc419fa18dc0057231ae1d8", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "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.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00", + "published-time": "2026-09-30T10:23:43+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "6c967db30708068f6eaedb6ed37973bd86de2241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/6c967db30708068f6eaedb6ed37973bd86de2241", + "reference": "6c967db30708068f6eaedb6ed37973bd86de2241", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "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": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-22T14:50:44+00:00", + "published-time": "2026-10-28T18:20:27+00:00" + }, + { + "name": "symplify/rule-doc-generator-contracts", + "version": "11.2.0", + "source": { + "type": "git", + "url": "https://github.com/symplify/rule-doc-generator-contracts.git", + "reference": "479cfcfd46047f80624aba931d9789e50475b5c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symplify/rule-doc-generator-contracts/zipball/479cfcfd46047f80624aba931d9789e50475b5c6", + "reference": "479cfcfd46047f80624aba931d9789e50475b5c6", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.2", + "rector/rector": "^0.15.10", + "symplify/easy-ci": "^11.1", + "symplify/easy-coding-standard": "^11.1", + "symplify/easy-testing": "^11.1", + "symplify/phpstan-extensions": "^11.1", + "symplify/phpstan-rules": "11.2.3.72", + "tomasvotruba/unused-public": "^0.0.34" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.2-dev" + } + }, + "autoload": { + "psr-4": { + "Symplify\\RuleDocGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Contracts for production code of RuleDocGenerator", + "support": { + "source": "https://github.com/symplify/rule-doc-generator-contracts/tree/11.2.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/rectorphp", + "type": "custom" + }, + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "abandoned": true, + "time": "2024-03-18T22:02:54+00:00", + "published-time": "2024-03-18T22:01:44+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "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/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00", + "published-time": "2025-12-08T11:19:39+00:00" + }, + { + "name": "tivie/htaccess-parser", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/tivie/php-htaccess-parser.git", + "reference": "0b6252a36b8ac0586306fd2db3dac78985c48589" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tivie/php-htaccess-parser/zipball/0b6252a36b8ac0586306fd2db3dac78985c48589", + "reference": "0b6252a36b8ac0586306fd2db3dac78985c48589", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Tivie\\HtaccessParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Estevão Soares dos Santos", + "email": "estevao@soares-dos-santos.com" + } + ], + "description": "A .htaccess parser and validator implemented in PHP", + "homepage": "https://github.com/tivie/php-htaccess-parser", + "keywords": [ + ".htaccess", + "htaccess" + ], + "support": { + "issues": "https://github.com/tivie/php-htaccess-parser/issues", + "source": "https://github.com/tivie/php-htaccess-parser/tree/0.4.0" + }, + "time": "2025-08-13T07:38:02+00:00", + "published-time": "2025-08-13T07:41:56+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/8a163706bbcac613733dba03f51e13b5eb1bb819", + "reference": "8a163706bbcac613733dba03f51e13b5eb1bb819", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@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/2.5.0" + }, + "time": "2026-10-05T23:14:19+00:00", + "published-time": "2026-10-05T23:14:25+00:00" } ], - "packages-dev": [], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": {}, + "platform": { + "php": "^8.4" + }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.11.0" } diff --git a/ci/fractor.php b/ci/fractor.php new file mode 100644 index 000000000..00d33d18b --- /dev/null +++ b/ci/fractor.php @@ -0,0 +1,18 @@ +withPaths(array_filter(explode("\n", (string)shell_exec("git ls-files | xargs ls -d 2>/dev/null")))) + ->withSets([ + ...FractorSettings::sets(true), + ]) + ->withRules([ + ...FractorSettings::rules(), + ]) + ->withOptions([ + ...FractorSettings::options(), + ]); diff --git a/ci/grumphp.yml b/ci/grumphp.yml new file mode 100644 index 000000000..c3013e153 --- /dev/null +++ b/ci/grumphp.yml @@ -0,0 +1,24 @@ +imports: + - { resource: vendor/pluswerk/grumphp-config/grumphp.yml } +parameters: + convention.process_timeout: 240 + convention.security_checker_blocking: true + convention.jsonlint_ignore_pattern: { } + convention.xmllint_ignore_pattern: { } + convention.yamllint_ignore_pattern: { } + convention.phpcslint_ignore_pattern: { } + convention.phpcslint_exclude: { } + convention.xlifflint_ignore_pattern: { } + convention.rector_ignore_pattern: { } + convention.rector_enabled: true + convention.rector_config: rector.php + convention.rector_clear-cache: false + convention.fractor_ignore_pattern: { } + convention.fractor_enable: true + convention.fractor_config: fractor.php + convention.fractor_clear-cache: false + convention.phpstan_level: null +grumphp: + tasks: + phpunit: + always_execute: true diff --git a/ci/phpstan-baseline.neon b/ci/phpstan-baseline.neon new file mode 100644 index 000000000..aab499115 --- /dev/null +++ b/ci/phpstan-baseline.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/ci/phpstan.neon b/ci/phpstan.neon new file mode 100644 index 000000000..e178ba6a0 --- /dev/null +++ b/ci/phpstan.neon @@ -0,0 +1,7 @@ +includes: + - phpstan-baseline.neon + - vendor/andersundsehr/phpstan-git-files/extension.php + +parameters: + level: 8 + reportUnmatchedIgnoredErrors: false diff --git a/ci/phpunit.xml.dist b/ci/phpunit.xml.dist new file mode 100644 index 000000000..13b9b0d5e --- /dev/null +++ b/ci/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests + + + + + src + + + diff --git a/ci/rector.php b/ci/rector.php new file mode 100644 index 000000000..be4263620 --- /dev/null +++ b/ci/rector.php @@ -0,0 +1,39 @@ +parallel(); + $rectorConfig->importNames(); + $rectorConfig->importShortClasses(); + $rectorConfig->removeUnusedImports(); + $rectorConfig->cacheDirectory('./var/cache/rector'); + + $rectorConfig->paths( + array_filter(explode("\n", (string)shell_exec("git ls-files | xargs ls -d 2>/dev/null | grep -E '\.(php)$'"))) + ); + + // define sets of rules + $rectorConfig->sets( + [ + ...RectorSettings::sets(true), + ], + ); + + // remove some rules + // ignore some files + $rectorConfig->skip( + [ + ...RectorSettings::skip(), + + /** + * rector should not touch these files + */ + //__DIR__ . '/src/Example', + //__DIR__ . '/src/Example.php', + ] + ); +}; diff --git a/ci/src/Commands/BuildGlyphsCommand.php b/ci/src/Commands/BuildGlyphsCommand.php index 1b7798bb6..7e6662317 100644 --- a/ci/src/Commands/BuildGlyphsCommand.php +++ b/ci/src/Commands/BuildGlyphsCommand.php @@ -2,6 +2,7 @@ namespace Webdevops\Build\Commands; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -19,10 +20,9 @@ * Writes the glyph table the README banner draws its texts with. * Only needed to change a font or to add characters; see ci/resources/readme-banner/README.md. */ +#[AsCommand(name: 'readme-banner:build-glyphs')] class BuildGlyphsCommand extends Command { - protected static $defaultName = 'readme-banner:build-glyphs'; - protected function configure(): void { $this @@ -49,14 +49,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('"%s" is not of the form key=file, e.g. mono-bold=JetBrainsMono-Bold.ttf', $argument)); return Command::INVALID; } + [$key, $file] = explode('=', $argument, 2); $fonts[$key] = $file; } + $file = $input->getOption('output'); - if (file_put_contents($file, (new GlyphTableBuilder())->build($fonts)) === false) { + if (file_put_contents($file, new GlyphTableBuilder()->build($fonts)) === false) { $output->writeln(sprintf('Cannot write %s', $file)); return Command::FAILURE; } + $output->writeln('Wrote ' . $file); return Command::SUCCESS; } diff --git a/ci/src/Commands/GithubCommand.php b/ci/src/Commands/GithubCommand.php index 96b7c36a8..00f6dc69d 100644 --- a/ci/src/Commands/GithubCommand.php +++ b/ci/src/Commands/GithubCommand.php @@ -4,6 +4,7 @@ use BlueM\Tree; use BlueM\Tree\Node; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -12,35 +13,59 @@ use Webdevops\Build\FileReader; use Webdevops\Build\GithubJobBuilder; +use function array_filter; +use function array_map; +use function array_values; +use function file; +use function file_exists; +use function file_put_contents; +use function is_string; use function ksort; +use function str_contains; +use function str_pad; +use function trim; +/** + * Writes .github/workflows/build.yaml: a build and a publish job for every Dockerfile under docker/. + */ +#[AsCommand(name: 'github:generate-ci')] class GithubCommand extends Command { - protected $output; - protected $fileReader; - protected $jobBuilder; - protected $jobs = []; - protected $deepestLevel = 0; - protected $blacklist = []; - protected $_settings = []; + private OutputInterface $output; - protected static $defaultName = 'github:generate-ci'; + private readonly FileReader $fileReader; - public function __construct() + private readonly GithubJobBuilder $jobBuilder; + + /** + * @var array> + */ + private array $jobs = []; + + /** + * @var list + */ + private array $blacklist = []; + + /** + * @param string $rootPath the repository root + */ + public function __construct(private readonly string $rootPath = __DIR__ . '/../../..') { - $this->fileReader = new FileReader(); - $this->jobBuilder = new GithubJobBuilder(); + $this->fileReader = new FileReader($this->rootPath); + $this->jobBuilder = new GithubJobBuilder($this->rootPath); parent::__construct(); $this->addOption('blacklist', 'b', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY); - $this->_settings = Yaml::parseFile(__DIR__ . '/../../../conf/console.yml'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->blacklist = $input->getOption('blacklist'); - if (empty($this->blacklist) && file_exists(__DIR__ . '/../../BLACKLIST')) { - $this->blacklist = file(__DIR__ . '/../../BLACKLIST', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $this->blacklist = $this->blacklistEntries($input->getOption('blacklist')); + if ($this->blacklist === [] && file_exists($this->rootPath . '/ci/BLACKLIST')) { + $this->blacklist = $this->blacklistEntries(file($this->rootPath . '/ci/BLACKLIST', FILE_IGNORE_NEW_LINES) ?: []); } + + $this->jobs = []; $this->output = $output; $tree = $this->buildTree(); foreach ($tree->getRootNodes() as $rootNode) { @@ -51,8 +76,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->jobs = [ 'validate-automation' => $this->jobBuilder->getValidationConfig(), + 'check-ci-code' => $this->jobBuilder->getCodeCheckConfig(), ...$this->jobs, - ];; + ]; $buildYaml = [ 'name' => 'build', 'on' => [ @@ -84,61 +110,59 @@ protected function execute(InputInterface $input, OutputInterface $output) 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK, ); - file_put_contents(__DIR__ . '/../../../.github/workflows/build.yaml', $yamlString); - return 0; + file_put_contents($this->rootPath . '/.github/workflows/build.yaml', $yamlString); + return Command::SUCCESS; } private function traverse(Node $node): void { - $line = 'Processing ' . $node->getName(); + $line = 'Processing ' . $node->get('name'); $nodeAr = $node->toArray(); $nodeAr['level'] = $node->getLevel(); $nodeAr['hasChildren'] = $node->hasChildren(); - // BlueM\Tree\Node lowercases all property keys internally, so the - // 'imageParent' key set by FileReader comes back as 'imageparent'. - // Restore the expected casing here, once, for consumers like - // GithubJobBuilder. - $nodeAr['imageParent'] = $nodeAr['imageparent'] ?? 0; - $nodeAr['imageParentRef'] = $nodeAr['imageparentref'] ?? 0; - $nodeAr['imageDependencies'] = $nodeAr['imagedependencies'] ?? []; - if ($node->getLevel() > $this->deepestLevel) { - $this->deepestLevel = $node->getLevel(); - } $this->jobs = [ ...$this->jobs, ...$this->jobBuilder->getJobsDescription($nodeAr), ]; if ($this->isNameBlacklisted($nodeAr['id'])) { -// $this->jobs[GithubJobBuilder::toJobId($node->getId())] = array_merge($this->jobs[GithubJobBuilder::toJobId($node->getId())], ['when' => 'manual']); $line .= ' *blacklisted*'; - if ($node->get('tag') !== $this->_settings['docker']['autoLatestTag']) { + if ($node->get('tag') !== $this->fileReader->autoLatestTag()) { unset($this->jobs[GithubJobBuilder::toJobId($node->getId())]); unset($this->jobs[GithubJobBuilder::toJobId($node->getId()) . '_publish']); } } + $this->output->write([str_pad('', $node->getLevel() - 1, "\t", STR_PAD_LEFT), $line, PHP_EOL]); foreach ($node->getChildren() as $childNode) { $this->traverse($childNode); } } + /** + * The entries of --blacklist or ci/BLACKLIST without surrounding whitespace, which no image name contains, + * and without empty entries, which would match every image. + * + * @param array $entries + * @return list + */ + private function blacklistEntries(array $entries): array + { + $entries = array_map(fn (mixed $entry): string => is_string($entry) ? trim($entry) : '', $entries); + return array_values(array_filter($entries, fn (string $entry): bool => $entry !== '')); + } + private function isNameBlacklisted(string $name): bool { - foreach ($this->blacklist as $blacklistItem) { - if (strpos($name, $blacklistItem)) { - return true; - } - } - return false; + return array_any($this->blacklist, fn (string $blacklistItem): bool => str_contains($name, $blacklistItem)); } private function buildTree(): Tree { $data = []; - $dockerFiles = $this->fileReader->collectDockerfiles(); - foreach ($dockerFiles as $file) { - $data[] = $this->fileReader->getInfo($file[0]); + foreach ($this->fileReader->collectDockerfiles() as $dockerfile) { + $data[] = $this->fileReader->getInfo($dockerfile); } + return new Tree($data); } } diff --git a/ci/src/Commands/ReadmeBannerCommand.php b/ci/src/Commands/ReadmeBannerCommand.php index c9ee512e9..a8ce43146 100644 --- a/ci/src/Commands/ReadmeBannerCommand.php +++ b/ci/src/Commands/ReadmeBannerCommand.php @@ -2,6 +2,7 @@ namespace Webdevops\Build\Commands; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -10,15 +11,21 @@ use Webdevops\Build\ReadmeBanner\BannerGenerator; use function array_column; -use function dirname; /** * Renders the banner at the top of README.md from the images that are built. * Its texts are in conf/readme-banner.yml. */ +#[AsCommand(name: 'readme-banner:generate')] class ReadmeBannerCommand extends Command { - protected static $defaultName = 'readme-banner:generate'; + /** + * @param string $rootPath the repository root + */ + public function __construct(private readonly string $rootPath = __DIR__ . '/../../..') + { + parent::__construct(); + } protected function configure(): void { @@ -27,16 +34,18 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $fileReader = new FileReader(); + $fileReader = new FileReader($this->rootPath); $images = []; - foreach ($fileReader->collectDockerfiles() as [$dockerfile]) { + foreach ($fileReader->collectDockerfiles() as $dockerfile) { $info = $fileReader->getInfo($dockerfile); $images[] = ['image' => $info['image'], 'tag' => $info['tag'], 'from' => $info['baseImage']]; } - $banner = new BannerGenerator(dirname(__DIR__, 3)); + + $banner = new BannerGenerator($this->rootPath); foreach ($banner->generate($images, array_column(GithubJobBuilder::PLATFORMS, 'platform')) as $file) { $output->writeln('Wrote ' . $file); } + return Command::SUCCESS; } } diff --git a/ci/src/FileReader.php b/ci/src/FileReader.php index 1e2b27578..26db64504 100644 --- a/ci/src/FileReader.php +++ b/ci/src/FileReader.php @@ -2,37 +2,84 @@ namespace Webdevops\Build; -use RecursiveIteratorIterator; -use RegexIterator; use RecursiveDirectoryIterator; -use RecursiveRegexIterator; +use RecursiveIteratorIterator; +use RuntimeException; +use SplFileInfo; use Symfony\Component\Yaml\Yaml; class FileReader { - private $_settings; + /** + * @var array conf/console.yml + */ + private readonly array $settings; - public function __construct() + /** + * @param string $rootPath the repository root + */ + public function __construct(private readonly string $rootPath = __DIR__ . '/../..') { - $this->_settings = Yaml::parseFile(__DIR__ . '/../../conf/console.yml'); + $this->settings = Yaml::parseFile($this->rootPath . '/conf/console.yml'); } - public function collectDockerfiles() + /** + * The tag that is published as latest too, e.g. ubuntu-22.04. + */ + public function autoLatestTag(): string { - $dockerDir = new RecursiveDirectoryIterator(__DIR__ . '/../../docker/'); - $iterator = new RecursiveIteratorIterator($dockerDir); - return new RegexIterator($iterator, '/^.*Dockerfile$/i', RecursiveRegexIterator::GET_MATCH); + $tag = $this->settings['docker']['autoLatestTag'] ?? null; + if (!is_string($tag) || $tag === '') { + // An unquoted value such as 22.10 is a number in YAML and would lose its trailing zero + throw new RuntimeException('"docker: autoLatestTag" in conf/console.yml has to be a tag in quotes, e.g. \'ubuntu-22.04\'.'); + } + + return $tag; + } + + /** + * Every Dockerfile under docker/, relative to the repository root and sorted, e.g. docker/base/alpine/Dockerfile. + * Directories that are symbolic links, such as docker/php/8.5, are left out: they build the same Dockerfile. + * + * @return list + */ + public function collectDockerfiles(): array + { + $dockerfiles = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->rootPath . '/docker')); + foreach ($files as $file) { + if ($file instanceof SplFileInfo && preg_match('/^.*Dockerfile$/i', $file->getPathname())) { + $dockerfiles[] = substr($file->getPathname(), strlen($this->rootPath) + 1); + } + } + + sort($dockerfiles); + return $dockerfiles; } - public function getInfo(string $dockerfilePath) + /** + * The build tree node of a generated Dockerfile: image, tag, parent image and test settings. + * + * @param string $dockerfile path relative to the repository root, e.g. docker/base/alpine/Dockerfile + * @return array + */ + public function getInfo(string $dockerfile): array { - $content = file_get_contents($dockerfilePath); + $path = $this->rootPath . '/' . $dockerfile; + $content = is_file($path) ? file_get_contents($path) : false; + if ($content === false) { + throw new RuntimeException(sprintf('Cannot read %s.', $dockerfile)); + } + // Extract info from file header - preg_match('/# Dockerfile for webdevops\/(.*):(.*)/', $content, $headerMatches); + if (!preg_match('/# Dockerfile for webdevops\/(.*):(.*)/', $content, $headerMatches)) { + throw new RuntimeException(sprintf('%s has no "# Dockerfile for webdevops/:" header.', $dockerfile)); + } + $imageName = $headerMatches[1]; $tagName = $headerMatches[2]; $id = 'webdevops/' . $imageName . ':' . $tagName; - $regex = '/' . $this->_settings['dockerTest']['configuration']['imageConfigurationRegex'] . '/'; + $regex = '/' . $this->settings['dockerTest']['configuration']['imageConfigurationRegex'] . '/'; preg_match_all($regex, $id, $serverSpecMatches); $node = [ 'id' => $id, @@ -40,7 +87,7 @@ public function getInfo(string $dockerfilePath) 'image' => $imageName, 'tag' => $tagName, 'aliases' => [], - 'file' => $dockerfilePath, + 'file' => $dockerfile, 'parent' => 0, 'imageParent' => 0, 'imageParentRef' => null, @@ -50,22 +97,23 @@ public function getInfo(string $dockerfilePath) 'serverspec' => [ 'DOCKER_IMAGE' => $id, 'DOCKER_TAG' => $tagName, - 'OS_FAMILY' => $serverSpecMatches['OS_FAMILY'][0] ?? $this->_settings['dockerTest']['configuration']['default']['OS_FAMILY'], - 'OS_VERSION' => $serverSpecMatches['OS_VERSION'][0] ?? $this->_settings['dockerTest']['configuration']['default']['OS_VERSION'], + 'OS_FAMILY' => $serverSpecMatches['OS_FAMILY'][0] ?? $this->settings['dockerTest']['configuration']['default']['OS_FAMILY'], + 'OS_VERSION' => $serverSpecMatches['OS_VERSION'][0] ?? $this->settings['dockerTest']['configuration']['default']['OS_VERSION'], ], ]; // Additional serverSpec variables (only first match) - foreach ($this->_settings['dockerTest']['configuration']['image'] as $regex => $variables) { + foreach ($this->settings['dockerTest']['configuration']['image'] as $regex => $variables) { if (preg_match('#' . $regex . '#i', $id)) { $node['serverspec'] = array_merge($node['serverspec'], $variables); break; } } + // Only internal images must be contained in build tree preg_match_all('/^FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+\S+)?\s*$/mi', $content, $fromMatches); $parentImage = array_pop($fromMatches[1]); $node['baseImage'] = $parentImage; - if (strpos($parentImage, 'webdevops/') === 0) { + if ($parentImage !== null && str_starts_with($parentImage, 'webdevops/')) { // Real Docker image inheritance: the Dockerfile's FROM references // another internal webdevops/* image. This is the only case where // 'imageParent' driven OCI artifact propagation (build-contexts @@ -78,13 +126,14 @@ public function getInfo(string $dockerfilePath) $node['parent'] = $this->resolveInternalImageReference($parentImage); $node['imageParent'] = $node['parent']; $node['imageParentRef'] = $parentImage; - } else if ($node['id'] !== 'webdevops/toolbox:latest') { + } elseif ($node['id'] !== 'webdevops/toolbox:latest') { // Synthetic scheduling dependency only (e.g. to serialize CI // against the Toolbox job). The Dockerfile does NOT actually // build FROM this image, so it must never drive OCI artifact // download/upload or build-contexts overrides. $node['parent'] = 'webdevops/toolbox:latest'; } + // Additional internal image dependencies referenced via // `COPY --from=webdevops/...` (not a FROM parent). BuildKit's named // build-context override mechanism applies identically to @@ -95,15 +144,18 @@ public function getInfo(string $dockerfilePath) foreach (array_unique($copyFromMatches[1]) as $dependencyRef) { $node['imageDependencies'][$dependencyRef] = $this->resolveInternalImageReference($dependencyRef); } + // Treat *-official images - if (strpos($id, '-official:') !== false) { + if (str_contains($id, '-official:')) { $node['aliases'][] = $id; $node['id'] = $node['name'] = str_replace('-official:', ':', $id); $node['image'] = str_replace('-official', '', $node['image']); } - if ($tagName === $this->_settings['docker']['autoLatestTag']) { + + if ($tagName === $this->autoLatestTag()) { $node['aliases'][] = str_replace(':' . $tagName, ':latest', $id); } + return $node; } @@ -127,7 +179,7 @@ public function getInfo(string $dockerfilePath) private function resolveInternalImageReference(string $reference): string { $imageAndTag = substr($reference, strlen('webdevops/')); - if (strpos($imageAndTag, ':') !== false) { + if (str_contains($imageAndTag, ':')) { [$image, $tag] = explode(':', $imageAndTag, 2); } else { // `COPY --from=webdevops/toolbox` has no explicit tag; Docker @@ -135,13 +187,14 @@ private function resolveInternalImageReference(string $reference): string $image = $imageAndTag; $tag = 'latest'; } + $dockerfileExists = fn (string $tag): bool => file_exists( - __DIR__ . '/../../docker/' . $image . '/' . $tag . '/Dockerfile', + $this->rootPath . '/docker/' . $image . '/' . $tag . '/Dockerfile', ); - if ($tag === 'latest' && !$dockerfileExists($tag) && $dockerfileExists($this->_settings['docker']['autoLatestTag'])) { - $tag = $this->_settings['docker']['autoLatestTag']; + if ($tag === 'latest' && !$dockerfileExists($tag) && $dockerfileExists($this->autoLatestTag())) { + $tag = $this->autoLatestTag(); } + return 'webdevops/' . $image . ':' . $tag; } - } diff --git a/ci/src/GithubJobBuilder.php b/ci/src/GithubJobBuilder.php index b1473611e..e60ee0121 100644 --- a/ci/src/GithubJobBuilder.php +++ b/ci/src/GithubJobBuilder.php @@ -15,12 +15,12 @@ class GithubJobBuilder * that child jobs can deterministically reference the exported manifest * instead of relying on Buildx picking an arbitrary entry from index.json. */ - private const PARENT_OCI_TAG = 'ci-parent-image'; + private const string PARENT_OCI_TAG = 'ci-parent-image'; /** * Every image is built for these platforms. The README banner lists them too. */ - public const PLATFORMS = [ + public const array PLATFORMS = [ [ 'arch' => 'amd64', 'runner' => 'ubuntu-24.04', @@ -34,6 +34,16 @@ class GithubJobBuilder ]; /** + * @param string $rootPath the repository root + */ + public function __construct(private readonly string $rootPath = __DIR__ . '/../..') + { + } + + /** + * The build job and the publish job of a build tree node. + * + * @param array $node node from FileReader::getInfo() with level and hasChildren * @return array> */ public function getJobsDescription(array $node): array @@ -44,7 +54,7 @@ public function getJobsDescription(array $node): array $jobId = GithubJobBuilder::toJobId($node['name']); $hasParent = (bool)($node['parent'] ?? null); $imageDependencies = $this->imageDependencies($node); - $hasImageDependencies = !empty($imageDependencies); + $hasImageDependencies = $imageDependencies !== []; $hasChildren = !empty($node['hasChildren']); $parentJobId = $hasParent ? GithubJobBuilder::toJobId($node['parent']) : null; $needs = $hasParent ? $parentJobId . '_publish' : 'validate-automation'; @@ -56,6 +66,7 @@ public function getJobsDescription(array $node): array $pushTags[] = '-t "' . $alias . '"'; $pushTags[] = '-t "ghcr.io/' . $alias . '"'; } + return [ $jobId => [ 'strategy' => [ @@ -84,7 +95,7 @@ public function getJobsDescription(array $node): array 'if' => $hasImageDependencies ? '${{ github.ref == \'refs/heads/master\' }}' : null, 'uses' => 'docker/build-push-action@v7', 'with' => $this->buildPushWith($node), - ], fn ($value): bool => $value !== null), + ], fn (array|string|null $value): bool => $value !== null), $hasImageDependencies ? [ 'name' => 'Build (load locally, from parent artifact)', 'if' => '${{ github.ref != \'refs/heads/master\' }}', @@ -124,7 +135,7 @@ public function getJobsDescription(array $node): array // Reuse the tested local build, including on no-cache reruns. 'with' => array_merge( [ - 'context' => dirname(str_replace(__DIR__ . '/../../', '', $node['file'])), + 'context' => $this->context($node), 'platforms' => '${{ matrix.platform }}', ...$this->buildCacheWith($node), 'build-args' => implode("\n", [ @@ -210,18 +221,30 @@ public static function toJobId(string $name): string $name = strtolower($name); $name = str_replace('webdevops/', '', $name); $name = str_replace(['/', '.'], '-', $name); - $name = str_replace(':', '_', $name); - return $name; + return str_replace(':', '_', $name); + } + + /** + * The directory of the node's Dockerfile, relative to the repository root like the file itself. + * + * @param array $node + */ + private function context(array $node): string + { + return dirname($node['file']); } /** * Common `docker/build-push-action` inputs shared by the local build step * and the additional per-node variants (parent-context build, OCI export). + * + * @param array $node + * @return array */ private function buildPushWith(array $node): array { return [ - 'context' => dirname(str_replace(__DIR__ . '/../../', '', $node['file'])), + 'context' => $this->context($node), 'platforms' => '${{ matrix.platform }}', 'load' => true, 'tags' => 'ghcr.io/webdevops/' . $node['image'] . ':sha-${{ github.sha }}-${{ matrix.arch }}-' . $node['tag'], @@ -234,6 +257,10 @@ private function buildPushWith(array $node): array ]; } + /** + * @param array $node + * @return array{cache-from: string, cache-to: string} + */ private function buildCacheWith(array $node): array { // Full image/tag and architecture keep scopes unique and reusable @@ -257,6 +284,9 @@ private function buildCacheWith(array $node): array * synthetic scheduling dependency (e.g. on the Toolbox job for images * whose Dockerfile does not actually FROM/COPY an internal image), which * must never drive OCI artifact propagation. + * + * @param array $node + * @return array */ private function imageDependencies(array $node): array { @@ -265,9 +295,11 @@ private function imageDependencies(array $node): array $literalRef = $node['imageParentRef'] ?: $node['imageParent']; $dependencies[$literalRef] = $node['imageParent']; } + foreach ($node['imageDependencies'] ?? [] as $literalRef => $resolvedImage) { $dependencies[$literalRef] = $resolvedImage; } + return $dependencies; } @@ -277,6 +309,7 @@ private function imageDependencies(array $node): array * never downloaded twice (e.g. if an image happens to be both the FROM * parent and a COPY --from target). * + * @param array $imageDependencies * @return array> */ private function downloadParentImageSteps(array $imageDependencies): array @@ -329,6 +362,8 @@ private function getCiImagePath(string $imageId): string * be reused unchanged for every Buildx invocation of this node (tested * build and OCI export alike), otherwise a later invocation silently * resolves a dependency from the registry again. + * + * @param array $imageDependencies */ private function buildContexts(array $imageDependencies): string { @@ -336,21 +371,27 @@ private function buildContexts(array $imageDependencies): string foreach ($imageDependencies as $literalRef => $imageId) { $lines[] = $literalRef . '=oci-layout://' . $this->getCiImagePath($imageId) . ':' . self::PARENT_OCI_TAG; } + return implode("\n", $lines); } - + /** + * Shell lines of the serverspec step, none if the image has no spec. + * + * @param array $node + * @return list + */ private function serverSpec(array $node): array { $specFile = sprintf('spec/docker/%s_spec.rb', $node['image']); - if (!file_exists(__DIR__ . '/../../tests/serverspec/' . $specFile)) { + if (!file_exists($this->rootPath . '/tests/serverspec/' . $specFile)) { return []; } $testDockerfile = 'Dockerfile_test'; $specConfig = $node['serverspec']; $specConfig['DOCKERFILE'] = $testDockerfile; - $encodedJsonConfig = base64_encode(json_encode($specConfig)); + $encodedJsonConfig = base64_encode(json_encode($specConfig, JSON_THROW_ON_ERROR)); $script = [ 'cd tests/serverspec', 'echo "FROM ghcr.io/webdevops/' . $node['image'] . ':sha-${{ github.sha }}-${{ matrix.arch }}"-' . $node['tag'] . ' >> ' . $testDockerfile, @@ -361,20 +402,61 @@ private function serverSpec(array $node): array return $script; } + /** + * Shell lines of the container-structure-test step, none if the image has no suite. + * + * @param array $node + * @return list + */ private function structuredTests(array $node): array { $script = []; - if (file_exists(__DIR__ . '/../../tests/structure-test/' . $node['image'] . '/test.yaml')) { + if (file_exists($this->rootPath . '/tests/structure-test/' . $node['image'] . '/test.yaml')) { $script[] = 'cd tests/structure-test'; - if (file_exists(__DIR__ . '/../../tests/structure-test/' . $node['image'] . '/' . $node['tag'] . '/test.yaml')) { + if (file_exists($this->rootPath . '/tests/structure-test/' . $node['image'] . '/' . $node['tag'] . '/test.yaml')) { $script[] = '/usr/local/bin/container-structure-test test --image ghcr.io/webdevops/' . $node['image'] . ':sha-${{ github.sha }}-${{ matrix.arch }}-' . $node['tag'] . ' --config ' . $node['image'] . '/test.yaml --config ' . $node['image'] . '/' . $node['tag'] . '/test.yaml'; } else { $script[] = '/usr/local/bin/container-structure-test test --image ghcr.io/webdevops/' . $node['image'] . ':sha-${{ github.sha }}-${{ matrix.arch }}-' . $node['tag'] . ' --config ' . $node['image'] . '/test.yaml'; } } + return $script; } + /** + * Checks the PHP code in ci/ with GrumPHP: coding standard, PHPStan, Rector, composer audit and PHPUnit. + * No other job waits for it, so a finding does not stop the images from being built. + * + * @return array + */ + public function getCodeCheckConfig(): array + { + return [ + 'name' => 'Check ci/ (GrumPHP and PHPUnit)', + 'runs-on' => 'ubuntu-latest', + 'steps' => [ + [ + 'uses' => 'actions/checkout@v7', + 'with' => ['persist-credentials' => false], + ], + [ + 'name' => 'Run GrumPHP', + 'run' => implode("\n", [ + 'docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install', + // GrumPHP's BOM check needs "file"; git needs to trust the checkout, which belongs to another user + 'docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine sh -c \'' + . "apk add --no-cache file && git config --global --add safe.directory /app && vendor/bin/grumphp run --no-interaction'", + ]), + ], + ], + ]; + } + + /** + * The first job, which checks that the generated files are up to date. + * + * @return array + */ public function getValidationConfig(): array { return [ @@ -395,7 +477,8 @@ public function getValidationConfig(): array [ 'name' => 'Validate .github/workflows/build.yaml is up to date', 'run' => implode("\n", [ - 'docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install', + // The generators need no dev tools; check-ci-code installs those + 'docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install --no-dev', 'docker run --rm -v $PWD:/app -w /app webdevops/php:8.4-alpine ci/console github:generate-ci', 'git diff --exit-code --color=always', ]), diff --git a/ci/src/ReadmeBanner/BannerGenerator.php b/ci/src/ReadmeBanner/BannerGenerator.php index 5f5762318..9635eb11b 100644 --- a/ci/src/ReadmeBanner/BannerGenerator.php +++ b/ci/src/ReadmeBanner/BannerGenerator.php @@ -23,9 +23,9 @@ * Writes the light and dark banner at the top of README.md. * Texts come from conf/readme-banner.yml, everything else from the built images. */ -final class BannerGenerator +final readonly class BannerGenerator { - public function __construct(private readonly string $rootPath) + public function __construct(private string $rootPath) { } @@ -37,8 +37,13 @@ public function __construct(private readonly string $rootPath) public function generate(array $images, array $platforms): array { $texts = Yaml::parseFile($this->rootPath . '/conf/readme-banner.yml'); + $glyphs = file_get_contents($this->rootPath . '/ci/resources/readme-banner/glyphs.json'); + if ($glyphs === false) { + throw new RuntimeException('Cannot read ci/resources/readme-banner/glyphs.json.'); + } + $table = json_decode( - file_get_contents($this->rootPath . '/ci/resources/readme-banner/glyphs.json'), + $glyphs, true, 512, JSON_THROW_ON_ERROR, @@ -51,6 +56,7 @@ public function generate(array $images, array $platforms): array )); } } + $fonts = []; foreach ($table as $name => $font) { $fonts[$name] = GlyphFont::fromTable($name, $font); @@ -73,11 +79,14 @@ public function generate(array $images, array $platforms): array if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0777, true) && !is_dir(dirname($path))) { throw new RuntimeException(sprintf('Cannot create the directory of %s.', $file)); } + if (file_put_contents($path, $renderer->render($theme)) === false) { throw new RuntimeException(sprintf('Cannot write %s.', $file)); } + $written[] = $file; } + return $written; } } diff --git a/ci/src/ReadmeBanner/BannerRenderer.php b/ci/src/ReadmeBanner/BannerRenderer.php index 1357d2f1f..d3ef797ef 100644 --- a/ci/src/ReadmeBanner/BannerRenderer.php +++ b/ci/src/ReadmeBanner/BannerRenderer.php @@ -4,16 +4,18 @@ use RuntimeException; +use function array_chunk; use function array_key_first; use function array_key_last; use function array_map; +use function array_values; +use function is_string; use function array_slice; use function ceil; use function count; use function floor; use function implode; use function in_array; -use function intdiv; use function is_array; use function max; use function preg_replace; @@ -25,10 +27,14 @@ /** * Draws the README banner: an example image tag taken apart into web server, * variant, PHP version and base OS, with all options of each part. + * + * @phpstan-type Item array{chip: ?string, description: ?string, selected: bool, compact: bool} + * @phpstan-type Row array{items: list, note: ?string} + * @phpstan-type Card array{part: string, title: string, rows: list} */ final class BannerRenderer { - public const THEMES = [ + public const array THEMES = [ 'light' => [ 'background' => '#f6f8fa', 'border' => '#d0d7de', 'card' => '#ffffff', 'cardBorder' => '#d0d7de', 'text' => '#1f2328', 'muted' => '#59636e', 'chipBorder' => '#d0d7de', 'onAccent' => '#ffffff', @@ -41,26 +47,43 @@ final class BannerRenderer ], ]; - private const WIDTH = 1280; - private const MARGIN = 44; - private const TAG_SIZE = 52; - private const TAG_Y = 152; - private const BAR_Y = 170; - private const CARD_Y = 300; - private const CARD_GAP = 16; - private const CARD_PADDING = 22; - private const CHIP_SIZE = 19; - private const CHIP_HEIGHT = 32; - private const ROW_PITCH = 42; - private const DESCRIPTION_SIZE = 17; - private const NOTE_SIZE = 15; - private const NOTE_HEIGHT = 20; - private const NOTE_BASELINE = 12; - private const NOTE_GAP = 6; - private const NO_SUFFIX_LABEL = 'no suffix'; + private const int WIDTH = 1280; + + private const int MARGIN = 44; + + private const int TAG_SIZE = 52; + + private const int TAG_Y = 152; + + private const int BAR_Y = 170; + + private const int CARD_Y = 300; + + private const int CARD_GAP = 16; + + private const int CARD_PADDING = 22; + + private const int CHIP_SIZE = 19; + + private const int CHIP_HEIGHT = 32; + + private const int ROW_PITCH = 42; + + private const int DESCRIPTION_SIZE = 17; + + private const int NOTE_SIZE = 15; + + private const int NOTE_HEIGHT = 20; + + private const int NOTE_BASELINE = 12; + + private const int NOTE_GAP = 6; + + private const string NO_SUFFIX_LABEL = 'no suffix'; /** @var ?array{flavor: string, version: string, base: string} */ private ?array $example = null; + /** @var ?list}> */ private ?array $bases = null; @@ -80,22 +103,28 @@ public function render(string $theme): string $colors = self::THEMES[$theme]; $cards = $this->cards(); $layouts = array_map(fn (array $card): array => $this->rowLayout($card['rows']), $cards); - $cardHeight = 60 + max(array_map(fn (array $layout): int => $layout['height'], $layouts)) + 14; + $cardHeight = 60 + max(0, ...array_map(fn (array $layout): int => $layout['height'], $layouts)) + 14; $footerY = self::CARD_Y + $cardHeight + 50; $height = $footerY + 40; $svg = []; $svg[] = sprintf( '', - self::WIDTH, $height, self::WIDTH, $height, + self::WIDTH, + $height, + self::WIDTH, + $height, ); - $svg[] = '' . Svg::escape($this->setting('title')) . ''; + $svg[] = '' . Svg::escape($this->settingText('title')) . ''; $svg[] = '' . Svg::escape($this->description()) . ''; $svg[] = sprintf( '', - self::WIDTH - 2, $height - 2, $colors['background'], $colors['border'], + self::WIDTH - 2, + $height - 2, + $colors['background'], + $colors['border'], ); - $svg[] = $this->text('sans-semibold', $this->setting('heading'), self::WIDTH / 2, 76, 15, $colors['muted'], 2.6, 'middle'); + $svg[] = $this->text('sans-semibold', $this->settingText('heading'), self::WIDTH / 2, 76, 15, $colors['muted'], 2.6, 'middle'); $segmentCenters = $this->tag($svg, $colors); @@ -106,7 +135,12 @@ public function render(string $theme): string $this->connector($svg, $segmentCenters[$card['part']], $left + $cardWidth / 2, $color); $svg[] = sprintf( '', - Svg::number($left), self::CARD_Y, Svg::number($cardWidth), $cardHeight, $colors['card'], $colors['cardBorder'], + Svg::number($left), + self::CARD_Y, + Svg::number($cardWidth), + $cardHeight, + $colors['card'], + $colors['cardBorder'], ); $svg[] = $this->text('sans-semibold', $card['title'], $left + self::CARD_PADDING, self::CARD_Y + 38, 15, $color, 1.6); $inner = $cardWidth - 2 * self::CARD_PADDING; @@ -114,16 +148,17 @@ public function render(string $theme): string foreach ($card['rows'] as $r => $row) { $x = $left + self::CARD_PADDING; $y = self::CARD_Y + 60 + $layouts[$i]['tops'][$r]; - if (isset($row['note'])) { + if ($row['note'] !== null) { // A note explains the description above it, so it starts below that description $x = $descriptionX ?? $x; $svg[] = $this->text('sans-regular', $row['note'], $x, $y + self::NOTE_BASELINE, self::NOTE_SIZE, $colors['muted']); $x += $this->fonts['sans-regular']->width($row['note'], self::NOTE_SIZE); } else { - foreach ($row as $k => $item) { + foreach ($row['items'] as $k => $item) { if ($k > 0) { $x += 8; } + $x += $this->chip($svg, $colors, $color, $x, $y, $item['chip'], $item['selected'], $item['compact']); if ($item['description'] !== null) { $x += 12; @@ -135,6 +170,7 @@ public function render(string $theme): string } } } + $this->assertFits($x - $left - self::CARD_PADDING, $inner, sprintf('a row of the "%s" card', $card['title'])); } } @@ -148,7 +184,7 @@ public function render(string $theme): string * Top of each row relative to the first one, and the height of all rows. * A note sits right below the row it belongs to. * - * @param list> $rows + * @param list $rows * @return array{tops: list, height: int} */ private function rowLayout(array $rows): array @@ -156,25 +192,28 @@ private function rowLayout(array $rows): array $tops = []; $y = 0; foreach ($rows as $r => $row) { - $tops[$r] = $y; - $y += isset($row['note']) ? self::NOTE_HEIGHT : self::CHIP_HEIGHT; - if (isset($rows[$r + 1]) && !isset($rows[$r + 1]['note'])) { - $y += isset($row['note']) ? self::NOTE_GAP : self::ROW_PITCH - self::CHIP_HEIGHT; + $tops[] = $y; + $isNote = $row['note'] !== null; + $y += $isNote ? self::NOTE_HEIGHT : self::CHIP_HEIGHT; + if (isset($rows[$r + 1]) && $rows[$r + 1]['note'] === null) { + $y += $isNote ? self::NOTE_GAP : self::ROW_PITCH - self::CHIP_HEIGHT; } } + return ['tops' => $tops, 'height' => $y]; } /** * Content of the four cards. Each row holds one or more items, or a note in smaller text. * - * @return list|array{note: string}>}> + * @return list */ private function cards(): array { $example = $this->example(); $item = fn (?string $chip, ?string $description, bool $selected, bool $compact = false): array => ['chip' => $chip, 'description' => $description, 'selected' => $selected, 'compact' => $compact]; + $row = $this->row(...); $flavors = []; foreach ($this->matrix->flavors() as $flavor) { @@ -184,35 +223,46 @@ private function cards(): array $flavor, )); } - $flavors[] = [$item($flavor, $this->texts['flavors'][$flavor], $flavor === $example['flavor'])]; + + $flavors[] = $row($item($flavor, $this->texts['flavors'][$flavor], $flavor === $example['flavor'])); } $versions = []; - $chipsPerRow = $this->versionChipsPerRow(); - foreach ($this->matrix->versions() as $i => $version) { - $versions[intdiv($i, $chipsPerRow)][] = $item($version, null, $version === $example['version'], true); + foreach (array_chunk($this->matrix->versions(), $this->versionChipsPerRow()) as $chunk) { + $versions[] = $row(...array_map(fn (string $version): array => $item($version, null, $version === $example['version'], true), $chunk)); } $bases = []; foreach ($this->bases() as $base) { $chip = $base['suffix'] === ImageMatrix::NO_SUFFIX ? null : '-' . $base['suffix']; - $bases[] = [$item($chip, $base['text'], $base['suffix'] === $example['base'])]; + $bases[] = $row($item($chip, $base['text'], $base['suffix'] === $example['base'])); foreach ($base['notes'] as $note) { - $bases[] = ['note' => $note]; + $bases[] = ['items' => [], 'note' => $note]; } } return [ - ['part' => 'flavor', 'title' => $this->setting('cards', 'flavor'), 'rows' => $flavors], - ['part' => 'variant', 'title' => $this->setting('cards', 'variant'), 'rows' => [ - [$item(null, $this->setting('variants', 'production'), false)], - [$item('-dev', $this->setting('variants', 'dev'), true)], + ['part' => 'flavor', 'title' => $this->settingText('cards', 'flavor'), 'rows' => $flavors], + ['part' => 'variant', 'title' => $this->settingText('cards', 'variant'), 'rows' => [ + $row($item(null, $this->settingText('variants', 'production'), false)), + $row($item('-dev', $this->settingText('variants', 'dev'), true)), ]], - ['part' => 'version', 'title' => $this->setting('cards', 'version'), 'rows' => $versions], - ['part' => 'base', 'title' => $this->setting('cards', 'base'), 'rows' => $bases], + ['part' => 'version', 'title' => $this->settingText('cards', 'version'), 'rows' => $versions], + ['part' => 'base', 'title' => $this->settingText('cards', 'base'), 'rows' => $bases], ]; } + /** + * A row of items. + * + * @param Item ...$items + * @return Row + */ + private function row(array ...$items): array + { + return ['items' => array_values($items), 'note' => null]; + } + /** * The base OS options: tags without a suffix first, then the suffixes in the order of * conf/readme-banner.yml. Suffixes listed there that are not built are left out. @@ -224,6 +274,7 @@ private function bases(): array if ($this->bases !== null) { return $this->bases; } + $built = $this->matrix->baseSuffixes(); $texts = $this->texts['bases']['suffixes'] ?? []; foreach ($built as $suffix) { @@ -234,15 +285,18 @@ private function bases(): array )); } } + $bases = []; if (in_array(ImageMatrix::NO_SUFFIX, $built, true)) { - $bases[] = ['suffix' => ImageMatrix::NO_SUFFIX, ...$this->baseText(ImageMatrix::NO_SUFFIX, $this->setting('bases', 'default'))]; + $bases[] = ['suffix' => ImageMatrix::NO_SUFFIX, ...$this->baseText(ImageMatrix::NO_SUFFIX, $this->settingText('bases', 'default'))]; } + foreach ($texts as $suffix => $text) { if (in_array((string)$suffix, $built, true)) { $bases[] = ['suffix' => (string)$suffix, ...$this->baseText((string)$suffix, $text)]; } } + return $this->bases = $bases; } @@ -258,6 +312,7 @@ private function baseText(string $suffix, string $text): array if (!str_contains($text, '{release}')) { return ['text' => $text, 'notes' => []]; } + // Versions that follow each other with the same release become a range, e.g. 8.2–8.5 $ranges = []; $previous = null; @@ -267,17 +322,21 @@ private function baseText(string $suffix, string $text): array } else { $ranges[$release][] = [(string)$version, (string)$version]; } + $previous = $release; } + if (count($ranges) === 1) { - return ['text' => str_replace('{release}', (string)array_key_first($ranges), $text), 'notes' => []]; + return ['text' => str_replace('{release}', array_key_first($ranges), $text), 'notes' => []]; } + $notes = []; foreach ($ranges as $release => $releaseRanges) { $versions = implode(', ', array_map(fn (array $r): string => $r[0] === $r[1] ? $r[0] : $r[0] . '–' . $r[1], $releaseRanges)); - $notes[] = str_replace(['{release}', '{versions}'], [(string)$release, $versions], $this->setting('bases', 'releases')); + $notes[] = str_replace(['{release}', '{versions}'], [(string)$release, $versions], $this->settingText('bases', 'releases')); } - return ['text' => trim(preg_replace('/\s+/', ' ', str_replace('{release}', '', $text))), 'notes' => $notes]; + + return ['text' => trim((string)preg_replace('/\s+/', ' ', str_replace('{release}', '', $text))), 'notes' => $notes]; } /** @@ -290,8 +349,23 @@ private function setting(string ...$path): mixed if (!is_array($value) || !isset($value[$key])) { throw new RuntimeException(sprintf('conf/readme-banner.yml has no setting "%s".', implode(': ', $path))); } + $value = $value[$key]; } + + return $value; + } + + /** + * A text setting from conf/readme-banner.yml, e.g. settingText('cards', 'flavor'). + */ + private function settingText(string ...$path): string + { + $value = $this->setting(...$path); + if (!is_string($value)) { + throw new RuntimeException(sprintf('The setting "%s" in conf/readme-banner.yml must be a text.', implode(': ', $path))); + } + return $value; } @@ -306,6 +380,7 @@ private function example(): array if ($this->example !== null) { return $this->example; } + $flavor = $this->setting('example', 'flavor'); $base = (string)$this->setting('example', 'base'); if (!in_array($flavor, $this->matrix->flavors(), true)) { @@ -314,6 +389,7 @@ private function example(): array $flavor, )); } + $version = $base === ImageMatrix::NO_SUFFIX ? null : $this->matrix->newestVersion($flavor . '-dev', $base); if ($version === null) { throw new RuntimeException(sprintf( @@ -322,19 +398,25 @@ private function example(): array $base, )); } + return $this->example = ['flavor' => $flavor, 'version' => $version, 'base' => $base]; } + /** + * @return positive-int + */ private function versionChipsPerRow(): int { $inner = (self::WIDTH - 2 * self::MARGIN - 3 * self::CARD_GAP) / 4 - 2 * self::CARD_PADDING; - $widest = max(array_map(fn (string $v): float => $this->chipWidth($v, true), $this->matrix->versions())); + $widest = max(0.0, ...array_map(fn (string $v): float => $this->chipWidth($v, true), $this->matrix->versions())); return max(1, (int)(($inner + 8) / ($widest + 8))); } /** * Draws the example tag and returns the horizontal center of each coloured part. * + * @param list $svg + * @param array $colors * @return array */ private function tag(array &$svg, array $colors): array @@ -362,14 +444,22 @@ private function tag(array &$svg, array $colors): array $centers[$part] = $x + $width / 2; $svg[] = sprintf( '', - Svg::number($x + 4), self::BAR_Y, Svg::number($width - 8), $color, + Svg::number($x + 4), + self::BAR_Y, + Svg::number($width - 8), + $color, ); } + $x += $width; } + return $centers; } + /** + * @param list $svg + */ private function connector(array &$svg, float $from, float $to, string $color): void { $top = self::BAR_Y + 14; @@ -377,7 +467,12 @@ private function connector(array &$svg, float $from, float $to, string $color): $middle = ($top + $bottom) / 2; $svg[] = sprintf( '', - Svg::number($from), $top, Svg::number($middle), Svg::number($to), $bottom, $color, + Svg::number($from), + $top, + Svg::number($middle), + Svg::number($to), + $bottom, + $color, ); $svg[] = sprintf('', Svg::number($from), $top, $color); $svg[] = sprintf('', Svg::number($to), $bottom, $color); @@ -386,6 +481,8 @@ private function connector(array &$svg, float $from, float $to, string $color): /** * Draws a chip with an image name part; null draws a dashed "no suffix" chip. * + * @param list $svg + * @param array $colors * @return float the width of the chip */ private function chip(array &$svg, array $colors, string $color, float $x, float $y, ?string $text, bool $selected, bool $compact): float @@ -394,20 +491,30 @@ private function chip(array &$svg, array $colors, string $color, float $x, float if ($selected) { $svg[] = sprintf( '', - Svg::number($x), Svg::number($y), Svg::number($width), self::CHIP_HEIGHT, $color, + Svg::number($x), + Svg::number($y), + Svg::number($width), + self::CHIP_HEIGHT, + $color, ); } else { $svg[] = sprintf( '', - Svg::number($x + .75), Svg::number($y + .75), Svg::number($width - 1.5), Svg::number(self::CHIP_HEIGHT - 1.5), - $colors['chipBorder'], $text === null ? ' stroke-dasharray="4 3"' : '', + Svg::number($x + .75), + Svg::number($y + .75), + Svg::number($width - 1.5), + Svg::number(self::CHIP_HEIGHT - 1.5), + $colors['chipBorder'], + $text === null ? ' stroke-dasharray="4 3"' : '', ); } + if ($text === null) { $svg[] = $this->text('sans-medium', self::NO_SUFFIX_LABEL, $x + $width / 2, $y + self::CHIP_HEIGHT / 2 + 5.5, 15, $colors['muted'], 0, 'middle'); } else { $svg[] = $this->text('mono-medium', $text, $x + $width / 2, $y + self::CHIP_HEIGHT / 2 + 6.8, self::CHIP_SIZE, $selected ? $colors['onAccent'] : $colors['muted'], 0, 'middle'); } + return $width; } @@ -416,9 +523,14 @@ private function chipWidth(?string $text, bool $compact): float if ($text === null) { return $this->fonts['sans-medium']->width(self::NO_SUFFIX_LABEL, 15) + 20; } + return $this->fonts['mono-medium']->width($text, self::CHIP_SIZE) + ($compact ? 14 : 20); } + /** + * @param list $svg + * @param array $colors + */ private function footer(array &$svg, array $colors, int $y): void { $platforms = implode(' + ', $this->matrix->platforms()); @@ -427,24 +539,32 @@ private function footer(array &$svg, array $colors, int $y): void if ($i > 0) { $parts[] = [' · ', true]; } + $parts[] = [str_replace('{platforms}', $platforms, $line), false]; } + $font = $this->fonts['sans-medium']; $total = 0.0; foreach ($parts as [$text]) { $total += $font->width($text, 18); } + $this->assertFits($total, self::WIDTH - 2 * self::MARGIN, 'the footer'); $x = (self::WIDTH - $total) / 2; foreach ($parts as [$text, $isSeparator]) { $svg[] = sprintf( '', - $colors['muted'], $isSeparator ? ' opacity="0.55"' : '', $font->path($text, $x, $y, 18), + $colors['muted'], + $isSeparator ? ' opacity="0.55"' : '', + $font->path($text, $x, $y, 18), ); $x += $font->width($text, 18); } } + /** + * @param 'start'|'middle'|'end' $anchor + */ private function text(string $font, string $text, float $x, float $y, float $size, string $color, float $tracking = 0, string $anchor = 'start'): string { return sprintf('', $color, $this->fonts[$font]->path($text, $x, $y, $size, $tracking, $anchor)); @@ -456,7 +576,9 @@ private function assertFits(float $width, float $available, string $what): void throw new RuntimeException(sprintf( 'The README banner has no room for %s (%dpx needed, %dpx available). ' . 'Shorten the text in conf/readme-banner.yml or adjust the layout in ci/src/ReadmeBanner/BannerRenderer.php.', - $what, ceil($width), floor($available), + $what, + ceil($width), + floor($available), )); } } @@ -483,9 +605,12 @@ private function description(): string 'Image tag anatomy using webdevops/%s-dev:%s-%s as the example. ' . 'Web server: %s. Variant: no suffix for %s, -dev for %s. PHP version: %s. ' . 'Base OS: %s. %s.', - $example['flavor'], $example['version'], $example['base'], + $example['flavor'], + $example['version'], + $example['base'], $this->list($flavors), - $this->setting('variants', 'production'), $this->setting('variants', 'dev'), + $this->setting('variants', 'production'), + $this->setting('variants', 'dev'), $this->list($versions), implode(', ', $bases), $footer, @@ -500,6 +625,7 @@ private function list(array $items): string if (count($items) < 2) { return implode('', $items); } + return implode(', ', array_slice($items, 0, -1)) . ' or ' . $items[count($items) - 1]; } } diff --git a/ci/src/ReadmeBanner/GlyphFont.php b/ci/src/ReadmeBanner/GlyphFont.php index 627a8f7b1..a47e0cd92 100644 --- a/ci/src/ReadmeBanner/GlyphFont.php +++ b/ci/src/ReadmeBanner/GlyphFont.php @@ -20,25 +20,28 @@ * GitHub shows SVG files as images and does not load web fonts for them, * so every text in the banner is drawn as a path. */ -final class GlyphFont +final readonly class GlyphFont { /** * The fonts glyphs.json has to contain: the README banner draws with all of them. */ - public const FONTS = ['mono-bold', 'mono-medium', 'sans-regular', 'sans-medium', 'sans-semibold']; + public const array FONTS = ['mono-bold', 'mono-medium', 'sans-regular', 'sans-medium', 'sans-semibold']; /** * @param array $glyphs advance width and outline per character, in font units * @param array $kerning adjustment per character pair, in font units */ public function __construct( - private readonly string $name, - private readonly int $unitsPerEm, - private readonly array $glyphs, - private readonly array $kerning, + private string $name, + private int $unitsPerEm, + private array $glyphs, + private array $kerning, ) { } + /** + * @param array{unitsPerEm: int, glyphs: array, kerning: array} $table one font of glyphs.json + */ public static function fromTable(string $name, array $table): self { return new self($name, $table['unitsPerEm'], $table['glyphs'], $table['kerning']); @@ -51,6 +54,7 @@ public function width(string $text, float $size, float $tracking = 0.0): float foreach ($characters as $i => $character) { $width += $this->glyphs[$character][0] + $this->kern($characters, $i); } + return $width * $size / $this->unitsPerEm + $tracking * (count($characters) - 1); } @@ -64,6 +68,7 @@ public function path(string $text, float $x, float $y, float $size, float $track if ($anchor !== 'start') { $x -= $this->width($text, $size, $tracking) / ($anchor === 'middle' ? 2 : 1); } + $scale = $size / $this->unitsPerEm; $path = []; $characters = $this->characters($text); @@ -72,8 +77,10 @@ public function path(string $text, float $x, float $y, float $size, float $track if ($outline !== '') { $path[] = $this->place($outline, $x, $y, $scale); } + $x += ($advance + $this->kern($characters, $i)) * $scale + $tracking; } + return implode(' ', $path); } @@ -95,6 +102,7 @@ private function characters(string $text): array )); } } + return $characters; } @@ -106,6 +114,7 @@ private function kern(array $characters, int $i): int if (!isset($characters[$i + 1])) { return 0; } + return $this->kerning[$characters[$i] . $characters[$i + 1]] ?? 0; } @@ -117,15 +126,17 @@ private function kern(array $characters, int $i): int private function place(string $outline, float $x, float $y, float $scale): string { $isX = true; - $tokens = preg_split('/\s+/', trim($outline)); + $tokens = preg_split('/\s+/', trim($outline)) ?: []; foreach ($tokens as $i => $token) { if (ctype_alpha($token)) { $isX = true; continue; } + $tokens[$i] = Svg::number($isX ? $x + (float)$token * $scale : $y - (float)$token * $scale); $isX = !$isX; } + return implode(' ', $tokens); } } diff --git a/ci/src/ReadmeBanner/Glyphs/BinaryData.php b/ci/src/ReadmeBanner/Glyphs/BinaryData.php index 0896b832d..1bef1a77e 100644 --- a/ci/src/ReadmeBanner/Glyphs/BinaryData.php +++ b/ci/src/ReadmeBanner/Glyphs/BinaryData.php @@ -12,9 +12,9 @@ /** * Big-endian reader for the binary tables of a font file. */ -final class BinaryData +final readonly class BinaryData { - public function __construct(private readonly string $bytes) + public function __construct(private string $bytes) { } diff --git a/ci/src/ReadmeBanner/Glyphs/GlyphTableBuilder.php b/ci/src/ReadmeBanner/Glyphs/GlyphTableBuilder.php index c3e66df82..83173f61d 100644 --- a/ci/src/ReadmeBanner/Glyphs/GlyphTableBuilder.php +++ b/ci/src/ReadmeBanner/Glyphs/GlyphTableBuilder.php @@ -33,12 +33,12 @@ final class GlyphTableBuilder /** * Font families by the first part of the font key, e.g. "mono" in "mono-bold". */ - public const SOURCES = [ + public const array SOURCES = [ 'mono' => 'JetBrains Mono, SIL Open Font License 1.1, see OFL-JetBrainsMono.txt', 'sans' => 'Inter, SIL Open Font License 1.1, see OFL-Inter.txt', ]; - private const EXTRA_CHARACTERS = '·–—…‘’“”'; + private const string EXTRA_CHARACTERS = '·–—…‘’“”'; /** * Printable ASCII and a few typographic characters. @@ -51,6 +51,7 @@ public static function characters(): array for ($codePoint = 0x20; $codePoint < 0x7F; $codePoint++) { $characters[] = mb_chr($codePoint); } + return [...$characters, ...mb_str_split(self::EXTRA_CHARACTERS)]; } @@ -67,6 +68,7 @@ public function build(array $fontFiles): string implode(', ', array_map(fn (string $key): string => $key . '=', $missing)), )); } + $fonts = []; foreach ($fontFiles as $key => $file) { $family = explode('-', $key)[0]; @@ -77,8 +79,10 @@ public function build(array $fontFiles): string implode(' or ', array_map(fn (string $f): string => '"' . $f . '-"', array_keys(self::SOURCES))), )); } + $fonts[$key] = $this->buildFont(new TrueTypeFont($file), self::SOURCES[$family]); } + return $this->dump($fonts); } @@ -94,6 +98,7 @@ private function buildFont(TrueTypeFont $font, string $source): array if ($glyph === null) { throw new RuntimeException(sprintf('%s has no glyph for "%s".', $font->path(), $character)); } + $glyphIds[$character] = $glyph; $glyphs[] = [$character, $font->advanceWidth($glyph), $font->outline($glyph)]; } @@ -108,6 +113,7 @@ private function buildFont(TrueTypeFont $font, string $source): array } } } + usort($kerning, fn (array $a, array $b): int => strcmp($a[0], $b[0])); return ['source' => $source, 'unitsPerEm' => $font->unitsPerEm(), 'glyphs' => $glyphs, 'kerning' => $kerning]; @@ -138,6 +144,7 @@ private function dump(array $fonts): string )) . '}'; $lines[] = ' }' . (++$i < count($fonts) ? ',' : ''); } + $lines[] = '}'; return implode("\n", $lines) . "\n"; } diff --git a/ci/src/ReadmeBanner/Glyphs/GposKerning.php b/ci/src/ReadmeBanner/Glyphs/GposKerning.php index 99ef03688..4fc8a5e5c 100644 --- a/ci/src/ReadmeBanner/Glyphs/GposKerning.php +++ b/ci/src/ReadmeBanner/Glyphs/GposKerning.php @@ -17,30 +17,41 @@ */ final class GposKerning { - private const FEATURES = ['kern', 'dist']; + private const array FEATURES = ['kern', 'dist']; - private const SINGLE_ADJUSTMENT = 1; - private const PAIR_ADJUSTMENT = 2; - private const EXTENSION = 9; + private const int SINGLE_ADJUSTMENT = 1; - private const IGNORE_BASE_GLYPHS = 0x0002; - private const IGNORE_LIGATURES = 0x0004; - private const IGNORE_MARKS = 0x0008; - private const USE_MARK_FILTERING_SET = 0x0010; - private const MARK_ATTACHMENT_TYPE = 0xFF00; + private const int PAIR_ADJUSTMENT = 2; - private const X_ADVANCE = 0x0004; + private const int EXTENSION = 9; + + private const int IGNORE_BASE_GLYPHS = 0x0002; + + private const int IGNORE_LIGATURES = 0x0004; + + private const int IGNORE_MARKS = 0x0008; + + private const int USE_MARK_FILTERING_SET = 0x0010; + + private const int MARK_ATTACHMENT_TYPE = 0xFF00; + + private const int X_ADVANCE = 0x0004; /** @var array}>> lookups per preferred script tag */ private array $lookups = []; + /** @var array> */ private array $coverages = []; + /** @var array> */ private array $classDefinitions = []; + /** @var array */ private array $glyphClasses = []; + /** @var array */ private array $markAttachmentClasses = []; + /** @var list offsets of the mark glyph set coverages in GDEF */ private array $markSets = []; @@ -76,6 +87,7 @@ public function adjustment(int $first, int $second, bool $latin): int if ($this->isSkipped($glyph, $lookup)) { continue; } + foreach ($lookup['subtables'] as $offset) { $value = $this->singleAdjustment($offset, $glyph); if ($value !== null) { @@ -84,11 +96,14 @@ public function adjustment(int $first, int $second, bool $latin): int } } } + continue; } + if ($this->isSkipped($first, $lookup) || $this->isSkipped($second, $lookup)) { continue; } + foreach ($lookup['subtables'] as $offset) { $pair = $this->pairAdjustment($offset, $first, $second); if ($pair !== null) { @@ -98,6 +113,7 @@ public function adjustment(int $first, int $second, bool $latin): int } } } + return $advances[0] + $advances[1]; } @@ -113,6 +129,7 @@ private function lookups(array $scripts): array if (isset($this->lookups[$key])) { return $this->lookups[$key]; } + $scriptList = $this->gpos->uint16(4); $featureList = $this->gpos->uint16(6); $lookupList = $this->gpos->uint16(8); @@ -126,15 +143,18 @@ private function lookups(array $scripts): array } } } + if ($script === null) { return $this->lookups[$key] = []; } + $langSys = $this->gpos->uint16($script) !== 0 ? $script + $this->gpos->uint16($script) : null; for ($i = 0; $langSys === null && $i < $this->gpos->uint16($script + 2); $i++) { if ($this->gpos->tag($script + 4 + 6 * $i) === 'dflt') { $langSys = $script + $this->gpos->uint16($script + 4 + 6 * $i + 4); } } + if ($langSys === null) { return $this->lookups[$key] = []; } @@ -143,20 +163,24 @@ private function lookups(array $scripts): array if ($this->gpos->uint16($langSys + 2) !== 0xFFFF) { $features[] = $this->gpos->uint16($langSys + 2); } + for ($i = 0; $i < $this->gpos->uint16($langSys + 4); $i++) { $features[] = $this->gpos->uint16($langSys + 6 + 2 * $i); } + $indices = []; foreach ($features as $feature) { $record = $featureList + 2 + 6 * $feature; if (!in_array($this->gpos->tag($record), self::FEATURES, true)) { continue; } + $table = $featureList + $this->gpos->uint16($record + 4); for ($i = 0; $i < $this->gpos->uint16($table + 2); $i++) { $indices[] = $this->gpos->uint16($table + 4 + 2 * $i); } } + $indices = array_unique($indices); sort($indices); @@ -173,8 +197,10 @@ private function lookups(array $scripts): array $type = $this->gpos->uint16($offset + 2); $offset += $this->gpos->uint32($offset + 4); } + $subtables[] = $offset; } + if ($type !== self::SINGLE_ADJUSTMENT && $type !== self::PAIR_ADJUSTMENT) { throw new RuntimeException(sprintf( '%s kerns with GPOS lookups of type %d; only single (1) and pair adjustments (2) are supported.', @@ -182,9 +208,11 @@ private function lookups(array $scripts): array $type, )); } + $markSet = ($flag & self::USE_MARK_FILTERING_SET) !== 0 ? $this->gpos->uint16($lookup + 6 + 2 * $count) : null; $lookups[] = ['type' => $type, 'flag' => $flag, 'markSet' => $markSet, 'subtables' => $subtables]; } + return $this->lookups[$key] = $lookups; } @@ -200,7 +228,7 @@ private function isSkipped(int $glyph, array $lookup): bool 1 => ($flag & self::IGNORE_BASE_GLYPHS) !== 0, 2 => ($flag & self::IGNORE_LIGATURES) !== 0, 3 => ($flag & self::IGNORE_MARKS) !== 0 - || ($lookup['markSet'] !== null && !isset($this->coverage($this->gdef, $this->markSets[$lookup['markSet']] ?? -1)[$glyph])) + || ($lookup['markSet'] !== null && $this->gdef instanceof BinaryData && !isset($this->coverage($this->gdef, $this->markSets[$lookup['markSet']] ?? -1)[$glyph])) || ($lookup['markSet'] === null && ($flag & self::MARK_ATTACHMENT_TYPE) !== 0 && ($flag & self::MARK_ATTACHMENT_TYPE) >> 8 !== ($this->markAttachmentClasses[$glyph] ?? 0)), default => false, @@ -213,10 +241,12 @@ private function singleAdjustment(int $offset, int $glyph): ?int if ($index === null) { return null; } + $format = $this->gpos->uint16($offset + 4); if ($this->gpos->uint16($offset) === 1) { return $this->xAdvance($offset + 6, $format); } + return $this->xAdvance($offset + 8 + $index * $this->valueSize($format), $format); } @@ -229,6 +259,7 @@ private function pairAdjustment(int $offset, int $first, int $second): ?array if ($index === null) { return null; } + $format1 = $this->gpos->uint16($offset + 4); $format2 = $this->gpos->uint16($offset + 6); $size1 = $this->valueSize($format1); @@ -243,6 +274,7 @@ private function pairAdjustment(int $offset, int $first, int $second): ?array return [$this->xAdvance($record + 2, $format1), $this->xAdvance($record + 2 + $size1, $format2)]; } } + return null; } @@ -253,6 +285,7 @@ private function pairAdjustment(int $offset, int $first, int $second): ?array if ($class1 >= $class1Count || $class2 >= $class2Count) { return null; } + $record = $offset + 16 + ($class1 * $class2Count + $class2) * ($size1 + $size2); return [$this->xAdvance($record, $format1), $this->xAdvance($record + $size1, $format2)]; } @@ -263,6 +296,7 @@ private function valueSize(int $format): int for ($bit = 0; $bit < 8; $bit++) { $size += ($format >> $bit & 1) * 2; } + return $size; } @@ -271,6 +305,7 @@ private function xAdvance(int $record, int $format): int if (($format & self::X_ADVANCE) === 0) { return 0; } + // XPlacement and YPlacement come first return $this->gpos->int16($record + $this->valueSize($format & 0x3)); } @@ -283,10 +318,12 @@ private function coverage(BinaryData $table, int $offset): array if ($offset < 0) { return []; } + $key = $offset * 2 + ($table === $this->gpos ? 0 : 1); if (isset($this->coverages[$key])) { return $this->coverages[$key]; } + $map = []; $count = $table->uint16($offset + 2); if ($table->uint16($offset) === 1) { @@ -302,6 +339,7 @@ private function coverage(BinaryData $table, int $offset): array } } } + return $this->coverages[$key] = $map; } @@ -314,6 +352,7 @@ private function classDefinition(BinaryData $table, int $offset): array if (isset($this->classDefinitions[$key])) { return $this->classDefinitions[$key]; } + $map = []; if ($table->uint16($offset) === 1) { $start = $table->uint16($offset + 2); @@ -328,6 +367,7 @@ private function classDefinition(BinaryData $table, int $offset): array } } } + return $this->classDefinitions[$key] = $map; } } diff --git a/ci/src/ReadmeBanner/Glyphs/OutlinePen.php b/ci/src/ReadmeBanner/Glyphs/OutlinePen.php index 4e336a7a2..bd46b3c58 100644 --- a/ci/src/ReadmeBanner/Glyphs/OutlinePen.php +++ b/ci/src/ReadmeBanner/Glyphs/OutlinePen.php @@ -4,7 +4,6 @@ use Webdevops\Build\ReadmeBanner\Svg; -use function array_key_last; use function count; use function implode; @@ -45,18 +44,21 @@ public function lineTo(array $point): void public function qCurveTo(array $points, bool $closedWithoutOnCurve = false): void { if ($closedWithoutOnCurve) { - $start = $this->middle($points[array_key_last($points)], $points[0]); + $start = $this->middle($points[count($points) - 1], $points[0]); $this->moveTo($start); $points[] = $start; } + if (count($points) === 1) { $this->lineTo($points[0]); return; } + $last = count($points) - 1; for ($i = 0; $i < $last - 1; $i++) { $this->add('Q', $points[$i], $this->middle($points[$i], $points[$i + 1])); } + $this->add('Q', $points[$last - 1], $points[$last]); } @@ -79,6 +81,7 @@ private function add(string $command, array ...$points): void foreach ($points as [$x, $y]) { $part .= ' ' . Svg::number($x) . ' ' . Svg::number($y); } + $this->parts[] = $part; } diff --git a/ci/src/ReadmeBanner/Glyphs/TrueTypeFont.php b/ci/src/ReadmeBanner/Glyphs/TrueTypeFont.php index e36edf6a5..440ce6b01 100644 --- a/ci/src/ReadmeBanner/Glyphs/TrueTypeFont.php +++ b/ci/src/ReadmeBanner/Glyphs/TrueTypeFont.php @@ -17,40 +17,61 @@ /** * Reads character mapping, advance widths, outlines and kerning from a font * file with TrueType outlines (.ttf). + * + * @phpstan-type Transform array{0: float|int, 1: float|int, 2: float|int, 3: float|int, 4: float|int, 5: float|int} */ final class TrueTypeFont { - private const ON_CURVE = 0x01; - private const X_SHORT = 0x02; - private const Y_SHORT = 0x04; - private const REPEAT = 0x08; - private const X_SAME_OR_POSITIVE = 0x10; - private const Y_SAME_OR_POSITIVE = 0x20; - private const CUBIC = 0x80; - - private const ARGS_ARE_WORDS = 0x0001; - private const ARGS_ARE_XY_VALUES = 0x0002; - private const HAVE_SCALE = 0x0008; - private const MORE_COMPONENTS = 0x0020; - private const HAVE_X_AND_Y_SCALE = 0x0040; - private const HAVE_TWO_BY_TWO = 0x0080; + private const int ON_CURVE = 0x01; + + private const int X_SHORT = 0x02; + + private const int Y_SHORT = 0x04; + + private const int REPEAT = 0x08; + + private const int X_SAME_OR_POSITIVE = 0x10; + + private const int Y_SAME_OR_POSITIVE = 0x20; + + private const int CUBIC = 0x80; + + private const int ARGS_ARE_WORDS = 0x0001; + + private const int ARGS_ARE_XY_VALUES = 0x0002; + + private const int HAVE_SCALE = 0x0008; + + private const int MORE_COMPONENTS = 0x0020; + + private const int HAVE_X_AND_Y_SCALE = 0x0040; + + private const int HAVE_TWO_BY_TWO = 0x0080; /** * Same order as fontTools' getBestCmap: Unicode full repertoire first, then BMP. */ - private const CMAP_PREFERENCES = [[3, 10], [0, 6], [0, 4], [3, 1], [0, 3], [0, 2], [0, 1], [0, 0]]; + private const array CMAP_PREFERENCES = [[3, 10], [0, 6], [0, 4], [3, 1], [0, 3], [0, 2], [0, 1], [0, 0]]; private readonly BinaryData $data; + /** @var array offset and length per table tag */ private array $tables = []; + /** @var array tables read so far */ private array $tableData = []; + private readonly int $unitsPerEm; + private readonly int $numGlyphs; + private readonly int $numberOfHMetrics; + private readonly bool $longLoca; + /** @var array glyph id per code point */ private array $cmap; + private ?GposKerning $kerning = null; public function __construct(private readonly string $path) @@ -58,27 +79,38 @@ public function __construct(private readonly string $path) if (!is_file($path)) { throw new RuntimeException(sprintf('The font file %s does not exist.', $path)); } - $this->data = new BinaryData(file_get_contents($path)); + + $bytes = file_get_contents($path); + if ($bytes === false) { + throw new RuntimeException(sprintf('Cannot read the font file %s.', $path)); + } + + $this->data = new BinaryData($bytes); $version = $this->data->tag(0); if ($version === 'OTTO') { throw new RuntimeException(sprintf('%s has PostScript (CFF) outlines. Use the .ttf file of the font.', $path)); } + if ($version === 'wOFF' || $version === 'wOF2') { throw new RuntimeException(sprintf('%s is a compressed web font. Use the .ttf file of the font.', $path)); } + if ($version !== "\x00\x01\x00\x00" && $version !== 'true') { throw new RuntimeException(sprintf('%s is not a TrueType font (.ttf).', $path)); } + $numTables = $this->data->uint16(4); for ($i = 0; $i < $numTables; $i++) { $record = 12 + 16 * $i; $this->tables[$this->data->tag($record)] = [$this->data->uint32($record + 8), $this->data->uint32($record + 12)]; } + foreach (['cmap', 'glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp'] as $required) { if (!isset($this->tables[$required])) { throw new RuntimeException(sprintf('%s has no "%s" table.', $path, $required)); } } + $head = $this->table('head'); $this->unitsPerEm = $head->uint16(18); $this->longLoca = $head->int16(50) === 1; @@ -127,6 +159,7 @@ public function kerning(int $first, int $second, bool $latin): int if (!isset($this->tables['GPOS'])) { return 0; } + $this->kerning ??= new GposKerning( $this->table('GPOS'), isset($this->tables['GDEF']) ? $this->table('GDEF') : null, @@ -141,6 +174,7 @@ private function table(string $tag): BinaryData [$offset, $length] = $this->tables[$tag]; $this->tableData[$tag] = $this->data->slice($offset, $length); } + return $this->tableData[$tag]; } @@ -156,10 +190,12 @@ private function readCmap(): array $record = 4 + 8 * $i; $subtables[$cmap->uint16($record) . '/' . $cmap->uint16($record + 2)] = $cmap->uint32($record + 4); } + foreach (self::CMAP_PREFERENCES as [$platform, $encoding]) { if (!isset($subtables[$platform . '/' . $encoding])) { continue; } + $offset = $subtables[$platform . '/' . $encoding]; return match ($cmap->uint16($offset)) { 4 => $this->readCmapFormat4($cmap, $offset), @@ -171,6 +207,7 @@ private function readCmap(): array )), }; } + throw new RuntimeException(sprintf('%s has no Unicode character map.', $this->path)); } @@ -199,9 +236,11 @@ private function readCmapFormat4(BinaryData $cmap, int $offset): array $glyph = ($glyph + $delta) & 0xFFFF; } } + $map[$codePoint] = $glyph; } } + return $map; } @@ -221,6 +260,7 @@ private function readCmapFormat12(BinaryData $cmap, int $offset): array $map[$codePoint] = $glyph + $codePoint - $start; } } + return $map; } @@ -229,23 +269,26 @@ private function readCmapFormat12(BinaryData $cmap, int $offset): array * components are drawn with their transformation, and each contour starts * at its last on-curve point. * - * @param ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $transform xx, xy, yx, yy, dx, dy + * @param ?Transform $transform xx, xy, yx, yy, dx, dy */ private function draw(int $glyph, OutlinePen $pen, ?array $transform, int $depth): void { if ($depth > 16) { throw new RuntimeException(sprintf('%s has components nested too deep in glyph %d.', $this->path, $glyph)); } + $data = $this->glyphData($glyph); if ($data === null) { return; } + $contours = $data->int16(0); if ($contours < 0) { foreach ($this->components($data) as [$component, $componentTransform]) { $combined = $transform === null ? $componentTransform : $this->combine($transform, $componentTransform); $this->draw($component, $pen, $combined, $depth + 1); } + return; } @@ -256,6 +299,7 @@ private function draw(int $glyph, OutlinePen $pen, ?array $transform, int $depth if ($offset !== 0) { $x += $offset; } + $points[$i] = $transform === null ? [$x, $y] : [ $transform[0] * $x + $transform[2] * $y + $transform[4], $transform[1] * $x + $transform[3] * $y + $transform[5], @@ -270,11 +314,13 @@ private function draw(int $glyph, OutlinePen $pen, ?array $transform, int $depth if ($contour === []) { continue; } + if (!in_array(true, $onCurve, true)) { $pen->qCurveTo($contour, true); $pen->closePath(); continue; } + // Rotate the contour so it ends with an on-curve point, which is where it starts $first = array_search(true, $onCurve, true) + 1; $contour = [...array_slice($contour, $first), ...array_slice($contour, 0, $first)]; @@ -290,9 +336,11 @@ private function draw(int $glyph, OutlinePen $pen, ?array $transform, int $depth } else { $pen->qCurveTo(array_slice($contour, 0, $next)); } + $contour = array_slice($contour, $next); $onCurve = array_slice($onCurve, $next); } + $pen->closePath(); } } @@ -302,6 +350,7 @@ private function glyphData(int $glyph): ?BinaryData if ($glyph >= $this->numGlyphs) { throw new RuntimeException(sprintf('%s refers to glyph %d, but has only %d glyphs.', $this->path, $glyph, $this->numGlyphs)); } + $loca = $this->table('loca'); if ($this->longLoca) { $start = $loca->uint32(4 * $glyph); @@ -310,9 +359,11 @@ private function glyphData(int $glyph): ?BinaryData $start = 2 * $loca->uint16(2 * $glyph); $end = 2 * $loca->uint16(2 * $glyph + 2); } + if ($end <= $start) { return null; } + [$glyfOffset] = $this->tables['glyf']; return $this->data->slice($glyfOffset + $start, $end - $start); } @@ -323,6 +374,7 @@ private function leftSideBearing(int $glyph): int if ($glyph < $this->numberOfHMetrics) { return $hmtx->int16(4 * $glyph + 2); } + return $hmtx->int16(4 * $this->numberOfHMetrics + 2 * ($glyph - $this->numberOfHMetrics)); } @@ -335,6 +387,7 @@ private function simpleGlyph(BinaryData $data, int $contours): array for ($c = 0; $c < $contours; $c++) { $endPoints[] = $data->uint16(10 + 2 * $c); } + $count = $endPoints === [] ? 0 : $endPoints[$contours - 1] + 1; $offset = 10 + 2 * $contours; $offset += 2 + $data->uint16($offset); @@ -346,14 +399,15 @@ private function simpleGlyph(BinaryData $data, int $contours): array for ($r = 0; $r <= $repeat; $r++) { $flags[] = $flag; } + if (($flag & self::CUBIC) !== 0 && ($flag & self::ON_CURVE) === 0) { throw new RuntimeException(sprintf('%s has cubic TrueType outlines, which are not supported.', $this->path)); } } - $points = []; + $xs = []; $x = 0; - foreach ($flags as $i => $flag) { + foreach ($flags as $flag) { if (($flag & self::X_SHORT) !== 0) { $delta = $data->uint8($offset++); $x += ($flag & self::X_SAME_OR_POSITIVE) !== 0 ? $delta : -$delta; @@ -361,8 +415,11 @@ private function simpleGlyph(BinaryData $data, int $contours): array $x += $data->int16($offset); $offset += 2; } - $points[$i] = [$x, 0]; + + $xs[] = $x; } + + $points = []; $y = 0; foreach ($flags as $i => $flag) { if (($flag & self::Y_SHORT) !== 0) { @@ -372,13 +429,15 @@ private function simpleGlyph(BinaryData $data, int $contours): array $y += $data->int16($offset); $offset += 2; } - $points[$i][1] = $y; + + $points[] = [$xs[$i], $y]; } + return [$points, array_slice($flags, 0, $count), $endPoints]; } /** - * @return list> glyph and transformation of each component + * @return list glyph and transformation of each component */ private function components(BinaryData $data): array { @@ -394,6 +453,7 @@ private function components(BinaryData $data): array $this->path, )); } + if (($flags & self::ARGS_ARE_WORDS) !== 0) { $dx = $data->int16($offset); $dy = $data->int16($offset + 2); @@ -403,6 +463,7 @@ private function components(BinaryData $data): array $dy = $data->int8($offset + 1); $offset += 2; } + $matrix = [1, 0, 0, 1]; if (($flags & self::HAVE_SCALE) !== 0) { $scale = $data->f2dot14($offset); @@ -415,17 +476,19 @@ private function components(BinaryData $data): array $matrix = [$data->f2dot14($offset), $data->f2dot14($offset + 2), $data->f2dot14($offset + 4), $data->f2dot14($offset + 6)]; $offset += 8; } + $components[] = [$glyph, [...$matrix, $dx, $dy]]; } while (($flags & self::MORE_COMPONENTS) !== 0); + return $components; } /** * The transformation that applies $inner first and then $outer, computed like fontTools' Transform.transform(). * - * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $outer - * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $inner - * @return array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} + * @param Transform $outer + * @param Transform $inner + * @return Transform */ private function combine(array $outer, array $inner): array { diff --git a/ci/src/ReadmeBanner/ImageMatrix.php b/ci/src/ReadmeBanner/ImageMatrix.php index 3c0e0bc68..01b0a8e1f 100644 --- a/ci/src/ReadmeBanner/ImageMatrix.php +++ b/ci/src/ReadmeBanner/ImageMatrix.php @@ -29,21 +29,26 @@ final class ImageMatrix { /** Base OS of tags without a suffix, e.g. 8.5 */ - public const NO_SUFFIX = ''; + public const string NO_SUFFIX = ''; /** Parts of an official PHP image tag that come before the release, e.g. fpm in 8.5-fpm-bookworm */ - private const PHP_IMAGE_VARIANTS = ['apache', 'cli', 'fpm', 'rc', 'zts']; + private const array PHP_IMAGE_VARIANTS = ['apache', 'cli', 'fpm', 'rc', 'zts']; /** @var array */ private array $flavors = []; + /** @var array */ private array $versions = []; + /** @var array>> PHP versions per image and base OS suffix, e.g. php-nginx-dev => alpine => [8.5] */ private array $imageVersions = []; + /** @var array */ private array $suffixes = []; + /** @var array> image webdevops/php is built from, per base OS suffix and PHP version */ private array $phpBaseImages = []; + /** @var array> releases() per base OS suffix, read once */ private array $releases = []; @@ -58,6 +63,7 @@ public function __construct(array $images, private readonly array $platforms) if (!preg_match('/^php(-[a-z0-9]+)*$/', $image['image'])) { continue; } + // Every PHP tag is shown, so a tag the banner cannot take apart stops it instead of being left out if (!preg_match('/^(?P\d+\.\d+)(?:-(?P.+))?$/', $image['tag'], $tag)) { throw new RuntimeException(sprintf( @@ -66,6 +72,7 @@ public function __construct(array $images, private readonly array $platforms) $image['tag'], )); } + $flavor = str_ends_with($image['image'], '-dev') ? substr($image['image'], 0, -4) : $image['image']; $suffix = $tag['suffix'] ?? self::NO_SUFFIX; $this->flavors[$flavor] = true; @@ -77,6 +84,7 @@ public function __construct(array $images, private readonly array $platforms) $this->phpBaseImages[$suffix][$tag['version']] = $image['from']; } } + if (!isset($this->flavors['php'])) { throw new RuntimeException('The README banner found no webdevops/php images to show.'); } @@ -98,7 +106,7 @@ public function flavors(): array public function versions(): array { $versions = array_keys($this->versions); - usort($versions, fn (string $a, string $b): int => version_compare($a, $b)); + usort($versions, $this->compareVersions(...)); return $versions; } @@ -108,7 +116,7 @@ public function versions(): array public function newestVersion(string $image, string $suffix): ?string { $versions = $this->imageVersions[$image][$suffix] ?? []; - usort($versions, fn (string $a, string $b): int => version_compare($a, $b)); + usort($versions, $this->compareVersions(...)); return $versions === [] ? null : end($versions); } @@ -118,7 +126,7 @@ public function newestVersion(string $image, string $suffix): ?string public function baseSuffixes(): array { // Array keys turn numeric suffixes such as "1" into integers - $suffixes = array_map('strval', array_keys($this->suffixes)); + $suffixes = array_map(strval(...), array_keys($this->suffixes)); sort($suffixes); return $suffixes; } @@ -133,13 +141,14 @@ public function releases(string $suffix): array { if (!isset($this->releases[$suffix])) { $baseImages = $this->phpBaseImages[$suffix] ?? []; - uksort($baseImages, fn (string $a, string $b): int => version_compare($a, $b)); + uksort($baseImages, $this->compareVersions(...)); $this->releases[$suffix] = []; foreach ($baseImages as $version => $baseImage) { - $tag = (string)$version . ($suffix === self::NO_SUFFIX ? '' : '-' . $suffix); + $tag = $version . ($suffix === self::NO_SUFFIX ? '' : '-' . $suffix); $this->releases[$suffix][(string)$version] = $this->releaseOf($baseImage, $tag); } } + return $this->releases[$suffix]; } @@ -162,6 +171,7 @@ private function releaseOf(?string $baseImage, string $tag): string // Leave out the digest and the registry, which may have a port $name = explode('@', (string)$baseImage, 2)[0]; $name = substr($name, (int)strrpos($name, '/')); + $colon = strrpos($name, ':'); $parts = $colon === false ? [] : explode('-', substr($name, $colon + 1)); $release = count($parts) > 1 ? end($parts) : ''; @@ -172,6 +182,12 @@ private function releaseOf(?string $baseImage, string $tag): string $tag, )); } + return $release; } + + private function compareVersions(string $a, string $b): int + { + return version_compare($a, $b); + } } diff --git a/ci/src/ReadmeBanner/Svg.php b/ci/src/ReadmeBanner/Svg.php index 852326c18..3bcacdd88 100644 --- a/ci/src/ReadmeBanner/Svg.php +++ b/ci/src/ReadmeBanner/Svg.php @@ -1,5 +1,7 @@ */ + private array $files = []; + + protected function tearDown(): void + { + foreach ($this->files as $file) { + @unlink($file); + } + } + + public function testWritesTheGlyphTable(): void + { + $output = $this->files[] = (string)tempnam(sys_get_temp_dir(), 'glyphs-'); + $font = $this->font(); + $tester = new CommandTester(new BuildGlyphsCommand()); + + $status = $tester->execute([ + 'fonts' => array_map(fn (string $key): string => $key . '=' . $font, GlyphFont::FONTS), + '--output' => $output, + ]); + + self::assertSame(Command::SUCCESS, $status); + self::assertSame('Wrote ' . $output . "\n", $tester->getDisplay()); + self::assertSame(GlyphFont::FONTS, array_keys(json_decode((string)file_get_contents($output), true, 512, JSON_THROW_ON_ERROR))); + } + + public function testFontsAreKeyAndFile(): void + { + $tester = new CommandTester(new BuildGlyphsCommand()); + + self::assertSame(Command::INVALID, $tester->execute(['fonts' => ['mono-bold=a.ttf', 'JetBrainsMono-Medium.ttf']])); + self::assertSame("\"JetBrainsMono-Medium.ttf\" is not of the form key=file, e.g. mono-bold=JetBrainsMono-Bold.ttf\n", $tester->getDisplay()); + } + + public function testWritesTheGlyphTableOfTheBannerByDefault(): void + { + $default = new BuildGlyphsCommand()->getDefinition()->getOption('output')->getDefault(); + + self::assertSame(dirname(__DIR__, 3) . '/ci/resources/readme-banner/glyphs.json', $default); + self::assertFileExists($default); + } + + /** + * A font with an empty glyph for every character of the banner. + */ + private function font(): string + { + $builder = new FontBuilder(); + $glyph = $builder->emptyGlyph(500); + foreach (GlyphTableBuilder::characters() as $character) { + $builder->map(mb_ord($character), $glyph); + } + + return $this->files[] = $builder->save(); + } +} diff --git a/ci/tests/Commands/GithubCommandTest.php b/ci/tests/Commands/GithubCommandTest.php new file mode 100644 index 000000000..5ee9a604d --- /dev/null +++ b/ci/tests/Commands/GithubCommandTest.php @@ -0,0 +1,219 @@ +repository = new TemporaryRepository(); + $this->repository->write('.github/workflows/build.yaml', ''); + } + + protected function tearDown(): void + { + $this->repository->remove(); + } + + public function testWritesTheWorkflowWithTheValidationFirstAndTheImageJobsSorted(): void + { + $workflow = $this->generate(); + + self::assertSame('build', $workflow['name']); + self::assertSame([ + 'schedule' => [['cron' => '0 0 * * *']], + 'push' => ['branches' => ['master']], + 'pull_request' => ['branches' => ['master']], + 'workflow_dispatch' => null, + ], $workflow['on']); + self::assertSame(self::ALL_JOBS, array_keys($workflow['jobs'])); + self::assertSame('Validate Automation', $workflow['jobs']['validate-automation']['name']); + } + + public function testJobsFollowTheImageTree(): void + { + $jobs = $this->generate()['jobs']; + + self::assertSame('validate-automation', $jobs['toolbox_latest']['needs']); + self::assertSame('toolbox_latest_publish', $jobs['base_alpine']['needs']); + self::assertSame('toolbox_latest_publish', $jobs['php_8-4']['needs']); + self::assertSame('base_ubuntu-22-04_publish', $jobs['base-app_ubuntu-22-04']['needs']); + self::assertSame('php_8-5-alpine_publish', $jobs['php-nginx_8-5-alpine']['needs']); + self::assertSame('php-nginx_8-5-alpine_publish', $jobs['php-nginx-dev_8-5-alpine']['needs']); + } + + public function testParentImagesArePassedOnBetweenJobs(): void + { + $jobs = $this->generate()['jobs']; + $steps = fn (string $job): array => array_column($jobs[$job]['steps'], null, 'name'); + + // Lower case keys of the build tree are turned back into imageParent and imageParentRef + self::assertSame( + 'webdevops/php:8.5-alpine=oci-layout://.ci-oci-image/php_8-5-alpine:ci-parent-image', + $steps('php-nginx_8-5-alpine')['Build (load locally, from parent artifact)']['with']['build-contexts'], + ); + self::assertArrayHasKey('Export image (OCI layout)', $steps('php_8-5-alpine')); + self::assertArrayHasKey('Export image (OCI layout)', $steps('toolbox_latest')); + self::assertArrayNotHasKey('Export image (OCI layout)', $steps('php_8-4')); + } + + public function testPublishesTheAliases(): void + { + $jobs = $this->generate()['jobs']; + $manifest = fn (string $job): string => array_column($jobs[$job]['steps'], 'run', 'name')['Create and push multi-arch manifest']; + + self::assertStringContainsString('-t "webdevops/base:latest"', $manifest('base_ubuntu-22-04_publish')); + self::assertStringContainsString('-t "webdevops/php-official:8.4"', $manifest('php_8-4_publish')); + } + + public function testPrintsTheImageTree(): void + { + $tester = new CommandTester(new GithubCommand($this->repository->path)); + $tester->execute([]); + + $display = $tester->getDisplay(); + self::assertStringContainsString("Processing webdevops/toolbox:latest\n", $display); + self::assertStringContainsString("\tProcessing webdevops/php:8.5-alpine\n", $display); + self::assertStringContainsString("\t\t\tProcessing webdevops/php-nginx-dev:8.5-alpine\n", $display); + } + + /** + * @return iterable, 1: list}> + */ + public static function blacklists(): iterable + { + yield 'part of the name' => [['php-nginx'], ['php-nginx-dev_8-5-alpine', 'php-nginx_8-5-alpine']]; + yield 'image and tag' => [['php-nginx:8.5-alpine'], ['php-nginx_8-5-alpine']]; + yield 'start of the name' => [['webdevops/toolbox'], ['toolbox_latest']]; + yield 'the auto latest tag stays' => [['base'], ['base_alpine']]; + yield 'two entries' => [['php:8.4', 'base:alpine'], ['base_alpine', 'php_8-4']]; + yield 'surrounding whitespace' => [[' php-nginx:8.5-alpine ', "\tbase:alpine"], ['base_alpine', 'php-nginx_8-5-alpine']]; + } + + /** + * @param list $blacklist + * @param list $removed build jobs that are left out together with their publish job + */ + #[DataProvider('blacklists')] + public function testBlacklistedImagesAreLeftOut(array $blacklist, array $removed): void + { + $tester = new CommandTester(new GithubCommand($this->repository->path)); + $tester->execute(['--blacklist' => $blacklist]); + + self::assertSame($this->jobsWithout($removed), array_keys($this->workflow()['jobs'])); + self::assertStringContainsString(' *blacklisted*', $tester->getDisplay()); + } + + /** + * @return iterable}> + */ + public static function emptyBlacklists(): iterable + { + yield 'empty entry' => [['']]; + yield 'blank entry' => [[' ']]; + yield 'option without value' => [[null]]; + } + + /** + * @param list $blacklist + */ + #[DataProvider('emptyBlacklists')] + public function testEmptyBlacklistEntriesMatchNothing(array $blacklist): void + { + $tester = new CommandTester(new GithubCommand($this->repository->path)); + + self::assertSame(Command::SUCCESS, $tester->execute(['--blacklist' => $blacklist])); + self::assertSame(self::ALL_JOBS, array_keys($this->workflow()['jobs'])); + self::assertStringNotContainsString('*blacklisted*', $tester->getDisplay()); + } + + public function testBlacklistFile(): void + { + $this->repository->write('ci/BLACKLIST', "php-nginx-dev \n\n \nphp:8.4\n"); + + self::assertSame($this->jobsWithout(['php-nginx-dev_8-5-alpine', 'php_8-4']), array_keys($this->generate()['jobs'])); + } + + public function testBlacklistOptionReplacesTheFile(): void + { + $this->repository->write('ci/BLACKLIST', "php-nginx-dev\n"); + $tester = new CommandTester(new GithubCommand($this->repository->path)); + $tester->execute(['--blacklist' => ['php:8.4']]); + + self::assertSame($this->jobsWithout(['php_8-4']), array_keys($this->workflow()['jobs'])); + } + + /** + * @return array + */ + private function generate(): array + { + $tester = new CommandTester(new GithubCommand($this->repository->path)); + self::assertSame(Command::SUCCESS, $tester->execute([])); + return $this->workflow(); + } + + /** + * @return array + */ + private function workflow(): array + { + return Yaml::parse($this->repository->read('.github/workflows/build.yaml')); + } + + /** + * @param list $jobs + * @return list + */ + private function jobsWithout(array $jobs): array + { + $publish = []; + foreach ($jobs as $job) { + $publish[] = $job . '_publish'; + } + + return array_values(array_diff(self::ALL_JOBS, $jobs, $publish)); + } +} diff --git a/ci/tests/Commands/ReadmeBannerCommandTest.php b/ci/tests/Commands/ReadmeBannerCommandTest.php new file mode 100644 index 000000000..a18d3681f --- /dev/null +++ b/ci/tests/Commands/ReadmeBannerCommandTest.php @@ -0,0 +1,44 @@ +repository = new TemporaryRepository(); + } + + protected function tearDown(): void + { + $this->repository->remove(); + } + + public function testRendersTheBannerFromTheDockerfiles(): void + { + $tester = new CommandTester(new ReadmeBannerCommand($this->repository->path)); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertSame("Wrote docs/assets/banner-light.svg\nWrote docs/assets/banner-dark.svg\n", $tester->getDisplay()); + self::assertSame( + 'Image tag anatomy using webdevops/php-nginx-dev:8.5-alpine as the example. ' + . 'Web server: php (PHP-FPM) or php-nginx (+ Nginx). Variant: no suffix for production, -dev for development. ' + . 'PHP version: 8.4 or 8.5. Base OS: no suffix for Debian (bookworm: 8.4; trixie: 8.5), -alpine for Alpine. ' + . 'Extends the official php images. linux/amd64 + linux/arm64.', + (string)new SimpleXMLElement($this->repository->read('docs/assets/banner-dark.svg'))->desc, + ); + } +} diff --git a/ci/tests/FileReaderTest.php b/ci/tests/FileReaderTest.php new file mode 100644 index 000000000..a46ee0af3 --- /dev/null +++ b/ci/tests/FileReaderTest.php @@ -0,0 +1,194 @@ +collectDockerfiles()); + } + + public function testImageWithAnOutsideBaseImageIsBuiltAfterTheToolbox(): void + { + $node = $this->info('base/ubuntu-22.04'); + + self::assertSame('webdevops/base:ubuntu-22.04', $node['id']); + self::assertSame('webdevops/base:ubuntu-22.04', $node['name']); + self::assertSame('base', $node['image']); + self::assertSame('ubuntu-22.04', $node['tag']); + self::assertSame('docker/base/ubuntu-22.04/Dockerfile', $node['file']); + self::assertSame('ubuntu:22.04', $node['baseImage']); + self::assertSame('webdevops/toolbox:latest', $node['parent']); + self::assertSame(0, $node['imageParent'], 'the toolbox is no image to build on'); + self::assertNull($node['imageParentRef']); + } + + public function testAutoLatestTagIsPublishedAsLatestToo(): void + { + self::assertSame(['webdevops/base:latest'], $this->info('base/ubuntu-22.04')['aliases']); + self::assertSame([], $this->info('base/alpine')['aliases']); + } + + public function testServerspecSettingsComeFromTheTag(): void + { + self::assertSame([ + 'DOCKER_IMAGE' => 'webdevops/base:ubuntu-22.04', + 'DOCKER_TAG' => 'ubuntu-22.04', + 'OS_FAMILY' => 'ubuntu', + 'OS_VERSION' => '22.04', + ], $this->info('base/ubuntu-22.04')['serverspec']); + } + + public function testFirstMatchingImageSettingsAreAdded(): void + { + $serverspec = $this->info('php-official/8.5-alpine')['serverspec']; + + self::assertSame('alpine', $serverspec['OS_FAMILY']); + self::assertSame('3', $serverspec['OS_VERSION'], 'not 12 from the second matching settings'); + self::assertSame('1', $serverspec['PHP_OFFICIAL']); + } + + public function testDefaultServerspecSettings(): void + { + $serverspec = $this->info('toolbox/latest')['serverspec']; + + self::assertSame('ubuntu', $serverspec['OS_FAMILY']); + self::assertSame('22.04', $serverspec['OS_VERSION']); + } + + public function testToolboxHasNoParent(): void + { + $node = $this->info('toolbox/latest'); + + self::assertSame(0, $node['parent']); + self::assertSame([], $node['imageDependencies']); + } + + public function testOfficialImageIsPublishedAsPhp(): void + { + $node = $this->info('php-official/8.4'); + + self::assertSame('webdevops/php:8.4', $node['id']); + self::assertSame('webdevops/php:8.4', $node['name']); + self::assertSame('php', $node['image']); + self::assertSame(['webdevops/php-official:8.4'], $node['aliases']); + self::assertSame('webdevops/php-official:8.4', $node['serverspec']['DOCKER_IMAGE']); + self::assertSame('debian', $node['serverspec']['OS_FAMILY']); + } + + public function testInternalParentImage(): void + { + $node = $this->info('php-nginx/8.5-alpine'); + + self::assertSame('webdevops/php:8.5-alpine', $node['parent']); + self::assertSame('webdevops/php:8.5-alpine', $node['imageParent']); + self::assertSame('webdevops/php:8.5-alpine', $node['imageParentRef']); + self::assertSame('webdevops/php:8.5-alpine', $node['baseImage']); + } + + public function testLatestParentResolvesToTheAutoLatestTag(): void + { + $node = $this->info('base-app/ubuntu-22.04'); + + self::assertSame('webdevops/base:ubuntu-22.04', $node['parent']); + self::assertSame('webdevops/base:ubuntu-22.04', $node['imageParent']); + self::assertSame('webdevops/base:latest', $node['imageParentRef'], 'the reference as written in the FROM line'); + } + + public function testCopyFromAnInternalImageIsADependency(): void + { + self::assertSame( + ['webdevops/toolbox' => 'webdevops/toolbox:latest'], + $this->info('base/ubuntu-22.04')['imageDependencies'], + 'an untagged reference means latest, and the toolbox has a latest directory', + ); + self::assertSame( + ['webdevops/toolbox:latest' => 'webdevops/toolbox:latest'], + $this->info('base/alpine')['imageDependencies'], + ); + } + + public function testAutoLatestTag(): void + { + self::assertSame('ubuntu-22.04', new FileReader(self::ROOT)->autoLatestTag()); + } + + /** + * @return iterable + */ + public static function invalidAutoLatestTags(): iterable + { + yield 'number without quotes' => [' autoLatestTag: 22.10']; + yield 'empty' => [" autoLatestTag: ''"]; + yield 'missing' => ['']; + } + + #[DataProvider('invalidAutoLatestTags')] + public function testAutoLatestTagHasToBeAQuotedTag(string $line): void + { + $repository = new TemporaryRepository(); + $repository->write('conf/console.yml', str_replace(" autoLatestTag: 'ubuntu-22.04'", $line, $repository->read('conf/console.yml'))); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"docker: autoLatestTag" in conf/console.yml has to be a tag in quotes, e.g. \'ubuntu-22.04\'.'); + new FileReader($repository->path)->getInfo('docker/base/ubuntu-22.04/Dockerfile'); + } finally { + $repository->remove(); + } + } + + public function testDockerfileWithoutHeaderIsAnError(): void + { + $repository = new TemporaryRepository(); + $repository->write('docker/broken/latest/Dockerfile', "FROM alpine:3.22\n"); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('docker/broken/latest/Dockerfile has no "# Dockerfile for webdevops/:" header.'); + new FileReader($repository->path)->getInfo('docker/broken/latest/Dockerfile'); + } finally { + $repository->remove(); + } + } + + public function testMissingDockerfileIsAnError(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot read docker/missing/latest/Dockerfile.'); + new FileReader(self::ROOT)->getInfo('docker/missing/latest/Dockerfile'); + } + + /** + * @return array + */ + private function info(string $directory): array + { + return new FileReader(self::ROOT)->getInfo('docker/' . $directory . '/Dockerfile'); + } +} diff --git a/ci/tests/Fixtures/repository/conf/console.yml b/ci/tests/Fixtures/repository/conf/console.yml new file mode 100644 index 000000000..2dada0a9b --- /dev/null +++ b/ci/tests/Fixtures/repository/conf/console.yml @@ -0,0 +1,21 @@ +--- +# Test copy of conf/console.yml with the keys ci/src reads + +docker: + autoLatestTag: 'ubuntu-22.04' + +dockerTest: + configuration: + imageConfigurationRegex: ':(?Palpine|debian|ubuntu|centos)-?(?P[^-]+)?' + default: + OS_FAMILY: 'ubuntu' + OS_VERSION: '22.04' + image: + 'php[^:]*:[0-9].[0-9]-alpine$': + OS_FAMILY: 'alpine' + OS_VERSION: '3' + PHP_OFFICIAL: '1' + 'php[^:]*:8.(0|1|2|3|4|5)': + OS_FAMILY: 'debian' + OS_VERSION: '12' + PHP_OFFICIAL: '1' diff --git a/ci/tests/Fixtures/repository/conf/readme-banner.yml b/ci/tests/Fixtures/repository/conf/readme-banner.yml new file mode 100644 index 000000000..bd8c80d13 --- /dev/null +++ b/ci/tests/Fixtures/repository/conf/readme-banner.yml @@ -0,0 +1,38 @@ +--- +# Test copy of conf/readme-banner.yml + +files: + light: 'docs/assets/banner-light.svg' + dark: 'docs/assets/banner-dark.svg' + +title: 'Test banner' + +heading: 'PICK YOUR IMAGE' + +example: + flavor: 'php-nginx' + base: 'alpine' + +cards: + flavor: 'WEB SERVER' + variant: 'VARIANT' + version: 'PHP VERSION' + base: 'BASE OS' + +flavors: + php: 'PHP-FPM' + php-nginx: '+ Nginx' + +variants: + production: 'production' + dev: 'development' + +bases: + default: 'Debian {release}' + releases: '{release}: {versions}' + suffixes: + alpine: 'Alpine' + +footer: + - 'Extends the official php images' + - '{platforms}' diff --git a/ci/tests/Fixtures/repository/docker/base-app/ubuntu-22.04/Dockerfile b/ci/tests/Fixtures/repository/docker/base-app/ubuntu-22.04/Dockerfile new file mode 100644 index 000000000..70a7a2dc6 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/base-app/ubuntu-22.04/Dockerfile @@ -0,0 +1,6 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/base-app:ubuntu-22.04 +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM webdevops/base:latest diff --git a/ci/tests/Fixtures/repository/docker/base/alpine/Dockerfile b/ci/tests/Fixtures/repository/docker/base/alpine/Dockerfile new file mode 100644 index 000000000..3f341516e --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/base/alpine/Dockerfile @@ -0,0 +1,8 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/base:alpine +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM alpine:3.22 + +COPY --from=webdevops/toolbox:latest /baselayout/sbin/* /sbin/ diff --git a/ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile b/ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile new file mode 100644 index 000000000..868d9f359 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile @@ -0,0 +1,8 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/base:ubuntu-22.04 +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM ubuntu:22.04 + +COPY --from=webdevops/toolbox /baselayout/sbin/* /sbin/ diff --git a/ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile.jinja2 b/ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile.jinja2 new file mode 100644 index 000000000..bd365936c --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile.jinja2 @@ -0,0 +1 @@ +{% extends "base.jinja2" %} diff --git a/ci/tests/Fixtures/repository/docker/php-nginx-dev/8.5-alpine/Dockerfile b/ci/tests/Fixtures/repository/docker/php-nginx-dev/8.5-alpine/Dockerfile new file mode 100644 index 000000000..1277bf673 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/php-nginx-dev/8.5-alpine/Dockerfile @@ -0,0 +1,6 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/php-nginx-dev:8.5-alpine +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM webdevops/php-nginx:8.5-alpine diff --git a/ci/tests/Fixtures/repository/docker/php-nginx/8.5-alpine/Dockerfile b/ci/tests/Fixtures/repository/docker/php-nginx/8.5-alpine/Dockerfile new file mode 100644 index 000000000..58c23b93a --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/php-nginx/8.5-alpine/Dockerfile @@ -0,0 +1,6 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/php-nginx:8.5-alpine +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM webdevops/php:8.5-alpine diff --git a/ci/tests/Fixtures/repository/docker/php-official/8.4/Dockerfile b/ci/tests/Fixtures/repository/docker/php-official/8.4/Dockerfile new file mode 100644 index 000000000..3c80892d5 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/php-official/8.4/Dockerfile @@ -0,0 +1,8 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/php-official:8.4 +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM php:8.4-fpm-bookworm + +COPY --from=webdevops/toolbox /baselayout/sbin/* /sbin/ diff --git a/ci/tests/Fixtures/repository/docker/php-official/8.5-alpine/Dockerfile b/ci/tests/Fixtures/repository/docker/php-official/8.5-alpine/Dockerfile new file mode 100644 index 000000000..b6c953df9 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/php-official/8.5-alpine/Dockerfile @@ -0,0 +1,8 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/php-official:8.5-alpine +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM php:8.5-fpm-alpine + +COPY --from=webdevops/toolbox /baselayout/sbin/* /sbin/ diff --git a/ci/tests/Fixtures/repository/docker/php-official/8.5/Dockerfile b/ci/tests/Fixtures/repository/docker/php-official/8.5/Dockerfile new file mode 100644 index 000000000..d35d37565 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/php-official/8.5/Dockerfile @@ -0,0 +1,8 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/php-official:8.5 +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM php:8.5-fpm-trixie + +COPY --from=webdevops/toolbox /baselayout/sbin/* /sbin/ diff --git a/ci/tests/Fixtures/repository/docker/php/8.4 b/ci/tests/Fixtures/repository/docker/php/8.4 new file mode 120000 index 000000000..59c4a3067 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/php/8.4 @@ -0,0 +1 @@ +../php-official/8.4 \ No newline at end of file diff --git a/ci/tests/Fixtures/repository/docker/toolbox/latest/Dockerfile b/ci/tests/Fixtures/repository/docker/toolbox/latest/Dockerfile new file mode 100644 index 000000000..5d54893f6 --- /dev/null +++ b/ci/tests/Fixtures/repository/docker/toolbox/latest/Dockerfile @@ -0,0 +1,8 @@ +#+++++++++++++++++++++++++++++++++++++++ +# Dockerfile for webdevops/toolbox:latest +# -- automatically generated -- +#+++++++++++++++++++++++++++++++++++++++ + +FROM alpine:3.22 + +RUN mkdir /baselayout diff --git a/ci/tests/Fixtures/repository/tests/serverspec/spec/docker/base_spec.rb b/ci/tests/Fixtures/repository/tests/serverspec/spec/docker/base_spec.rb new file mode 100644 index 000000000..e69de29bb diff --git a/ci/tests/Fixtures/repository/tests/structure-test/base/alpine/test.yaml b/ci/tests/Fixtures/repository/tests/structure-test/base/alpine/test.yaml new file mode 100644 index 000000000..90934da86 --- /dev/null +++ b/ci/tests/Fixtures/repository/tests/structure-test/base/alpine/test.yaml @@ -0,0 +1 @@ +schemaVersion: 2.0.0 diff --git a/ci/tests/Fixtures/repository/tests/structure-test/base/test.yaml b/ci/tests/Fixtures/repository/tests/structure-test/base/test.yaml new file mode 100644 index 000000000..90934da86 --- /dev/null +++ b/ci/tests/Fixtures/repository/tests/structure-test/base/test.yaml @@ -0,0 +1 @@ +schemaVersion: 2.0.0 diff --git a/ci/tests/GithubJobBuilderTest.php b/ci/tests/GithubJobBuilderTest.php new file mode 100644 index 000000000..e44b0e0d4 --- /dev/null +++ b/ci/tests/GithubJobBuilderTest.php @@ -0,0 +1,309 @@ + + */ + public static function jobIds(): iterable + { + yield 'image and tag' => ['webdevops/php-nginx:8.5-alpine', 'php-nginx_8-5-alpine']; + yield 'upper case' => ['webdevops/PHP:8.4', 'php_8-4']; + yield 'path' => ['webdevops/php/8.4', 'php-8-4']; + } + + #[DataProvider('jobIds')] + public function testJobId(string $name, string $expected): void + { + self::assertSame($expected, GithubJobBuilder::toJobId($name)); + } + + public function testBuildAndPublishJob(): void + { + $jobs = $this->jobs(); + + self::assertSame(['base_alpine', 'base_alpine_publish'], array_keys($jobs)); + self::assertSame('webdevops/base:alpine (${{ matrix.arch }})', $jobs['base_alpine']['name']); + self::assertSame(['fail-fast' => false, 'matrix' => ['include' => GithubJobBuilder::PLATFORMS]], $jobs['base_alpine']['strategy']); + self::assertSame('${{ matrix.runner }}', $jobs['base_alpine']['runs-on']); + self::assertSame('webdevops/dockerfile-build-env', $jobs['base_alpine']['container']); + self::assertSame('base_alpine', $jobs['base_alpine_publish']['needs']); + self::assertSame('${{github.ref == \'refs/heads/master\'}}', $jobs['base_alpine_publish']['if']); + } + + public function testJobWaitsForItsParent(): void + { + self::assertSame('toolbox_latest_publish', $this->jobs()['base_alpine']['needs']); + self::assertSame('validate-automation', $this->jobs(['parent' => 0])['base_alpine']['needs']); + } + + public function testBuildsTheDirectoryOfTheDockerfile(): void + { + $with = $this->step($this->jobs(), 'Build (load locally)')['with']; + + self::assertSame('docker/base/alpine', $with['context']); + self::assertSame('${{ matrix.platform }}', $with['platforms']); + self::assertTrue($with['load']); + self::assertSame('ghcr.io/webdevops/base:sha-${{ github.sha }}-${{ matrix.arch }}-alpine', $with['tags']); + self::assertSame('type=gha,scope=webdevops/base:alpine-${{ matrix.arch }}', $with['cache-from']); + self::assertSame('type=gha,scope=webdevops/base:alpine-${{ matrix.arch }},mode=max', $with['cache-to']); + self::assertSame('TARGETARCH=${{ matrix.arch }}', $with['build-args']); + } + + public function testImageWithoutDependenciesIsBuiltOnce(): void + { + $steps = $this->steps($this->jobs()); + + self::assertSame([ + 'actions/checkout@v7', + 'docker/setup-buildx-action@v4', + 'Build (load locally)', + 'run serverspec', + 'run structure-test', + 'Login to ghcr.io', + 'Push arch image', + ], array_keys($steps)); + self::assertArrayNotHasKey('if', $steps['Build (load locally)']); + self::assertSame( + 'docker push "ghcr.io/webdevops/base:sha-${{ github.sha }}-${{ matrix.arch }}"-alpine', + $steps['Push arch image']['run'], + ); + } + + public function testServerspecGetsTheSettingsOfTheImage(): void + { + $lines = explode("\n", $this->step($this->jobs(), 'run serverspec')['run']); + + self::assertSame('cd tests/serverspec', $lines[0]); + self::assertSame('echo "FROM ghcr.io/webdevops/base:sha-${{ github.sha }}-${{ matrix.arch }}"-alpine >> Dockerfile_test', $lines[1]); + self::assertSame('echo "COPY conf/ /" >> Dockerfile_test', $lines[2]); + self::assertSame('bundle install', $lines[3]); + $settings = ['DOCKER_IMAGE' => 'webdevops/base:alpine', 'DOCKER_TAG' => 'alpine', 'OS_FAMILY' => 'alpine', 'DOCKERFILE' => 'Dockerfile_test']; + self::assertSame( + 'bash serverspec.sh spec/docker/base_spec.rb webdevops/base:alpine ' . base64_encode(json_encode($settings, JSON_THROW_ON_ERROR)) . ' Dockerfile_test', + $lines[4], + ); + self::assertCount(5, $lines); + } + + public function testStructureTestUsesTheSuiteOfTheTagToo(): void + { + self::assertSame( + "cd tests/structure-test\n/usr/local/bin/container-structure-test test --image ghcr.io/webdevops/base:sha-\${{ github.sha }}-\${{ matrix.arch }}-alpine" + . ' --config base/test.yaml --config base/alpine/test.yaml', + $this->step($this->jobs(), 'run structure-test')['run'], + ); + self::assertSame( + "cd tests/structure-test\n/usr/local/bin/container-structure-test test --image ghcr.io/webdevops/base:sha-\${{ github.sha }}-\${{ matrix.arch }}-ubuntu-22.04" + . ' --config base/test.yaml', + $this->step($this->jobs(['tag' => 'ubuntu-22.04']), 'run structure-test')['run'], + ); + } + + public function testImageWithoutTestsHasNoTestSteps(): void + { + $steps = $this->steps($this->jobs(['image' => 'php-nginx'])); + + self::assertArrayNotHasKey('run serverspec', $steps); + self::assertArrayNotHasKey('run structure-test', $steps); + } + + public function testBranchBuildUsesTheParentImagesOfThisRun(): void + { + $steps = $this->steps($this->jobs([ + 'imageParent' => 'webdevops/base:ubuntu-22.04', + 'imageParentRef' => 'webdevops/base:latest', + 'imageDependencies' => ['webdevops/toolbox' => 'webdevops/toolbox:latest'], + ])); + + self::assertSame([ + 'actions/checkout@v7', + 'docker/setup-buildx-action@v4', + 'Download parent image (OCI layout): webdevops/base:ubuntu-22.04', + 'Download parent image (OCI layout): webdevops/toolbox:latest', + 'Build (load locally)', + 'Build (load locally, from parent artifact)', + 'run serverspec', + 'run structure-test', + 'Login to ghcr.io', + 'Push arch image', + ], array_keys($steps)); + self::assertSame( + ['name' => 'docker-parent-base_ubuntu-22-04-${{ matrix.arch }}', 'path' => '.ci-oci-image/base_ubuntu-22-04'], + $steps['Download parent image (OCI layout): webdevops/base:ubuntu-22.04']['with'], + ); + self::assertSame('${{ github.ref != \'refs/heads/master\' }}', $steps['Download parent image (OCI layout): webdevops/base:ubuntu-22.04']['if']); + self::assertSame('${{ github.ref == \'refs/heads/master\' }}', $steps['Build (load locally)']['if']); + self::assertArrayNotHasKey('build-contexts', $steps['Build (load locally)']['with']); + self::assertSame('${{ github.ref != \'refs/heads/master\' }}', $steps['Build (load locally, from parent artifact)']['if']); + self::assertSame( + "webdevops/base:latest=oci-layout://.ci-oci-image/base_ubuntu-22-04:ci-parent-image\n" + . 'webdevops/toolbox=oci-layout://.ci-oci-image/toolbox_latest:ci-parent-image', + $steps['Build (load locally, from parent artifact)']['with']['build-contexts'], + ); + } + + public function testParentThatIsAlsoCopiedFromIsDownloadedOnce(): void + { + $steps = $this->steps($this->jobs([ + 'imageParent' => 'webdevops/toolbox:latest', + 'imageParentRef' => 'webdevops/toolbox:latest', + 'imageDependencies' => ['webdevops/toolbox' => 'webdevops/toolbox:latest'], + ])); + + self::assertArrayHasKey('Download parent image (OCI layout): webdevops/toolbox:latest', $steps); + self::assertSame( + "webdevops/toolbox:latest=oci-layout://.ci-oci-image/toolbox_latest:ci-parent-image\n" + . 'webdevops/toolbox=oci-layout://.ci-oci-image/toolbox_latest:ci-parent-image', + $steps['Build (load locally, from parent artifact)']['with']['build-contexts'], + ); + self::assertCount(1, array_filter(array_keys($steps), fn (string $name): bool => str_starts_with($name, 'Download'))); + } + + public function testImageWithChildrenPassesItselfOn(): void + { + $steps = $this->steps($this->jobs(['hasChildren' => true, 'imageDependencies' => ['webdevops/toolbox' => 'webdevops/toolbox:latest']])); + + $export = $steps['Export image (OCI layout)']; + self::assertSame('${{ github.ref != \'refs/heads/master\' }}', $export['if']); + self::assertSame('docker/base/alpine', $export['with']['context']); + self::assertSame('webdevops/toolbox=oci-layout://.ci-oci-image/toolbox_latest:ci-parent-image', $export['with']['build-contexts']); + self::assertSame('type=oci,tar=false,name=ci-parent-image,dest=.ci-oci-image/base_alpine', $export['with']['outputs']); + self::assertSame([ + 'name' => 'docker-parent-base_alpine-${{ matrix.arch }}', + 'path' => '.ci-oci-image/base_alpine', + 'retention-days' => 1, + 'overwrite' => true, + 'compression-level' => 0, + 'if-no-files-found' => 'error', + ], $steps['Upload image (OCI layout)']['with']); + self::assertArrayNotHasKey('Export image (OCI layout)', $this->steps($this->jobs())); + } + + public function testPublishPushesEveryTagToBothRegistries(): void + { + $node = ['id' => 'webdevops/base:ubuntu-22.04', 'name' => 'webdevops/base:ubuntu-22.04', 'tag' => 'ubuntu-22.04', 'aliases' => ['webdevops/base:latest']]; + $steps = $this->steps($this->jobs($node), 'base_ubuntu-22-04_publish'); + + self::assertSame(['docker/setup-buildx-action@v4', 'Login to ghcr.io', 'Login to hub.docker.com', 'Create and push multi-arch manifest'], array_keys($steps)); + self::assertSame( + "set -euo pipefail\n" + . "for i in 1 2 3 4 5 6 7 8 9 10; do\n" + . " docker buildx imagetools create \\\n" + . " -t \"webdevops/base:ubuntu-22.04\" \\\n" + . " -t \"ghcr.io/webdevops/base:ubuntu-22.04\" \\\n" + . " -t \"webdevops/base:latest\" \\\n" + . " -t \"ghcr.io/webdevops/base:latest\" \\\n" + . " \"ghcr.io/webdevops/base:sha-\${{ github.sha }}-amd64-ubuntu-22.04\" \\\n" + . " \"ghcr.io/webdevops/base:sha-\${{ github.sha }}-arm64-ubuntu-22.04\" && exit 0\n" + . " sleep \$((i*i))\n" + . "done\n" + . 'exit 1', + $steps['Create and push multi-arch manifest']['run'], + ); + } + + public function testValidationJobChecksTheGeneratedFiles(): void + { + $job = new GithubJobBuilder(self::ROOT)->getValidationConfig(); + + self::assertSame('Validate Automation', $job['name']); + self::assertSame([ + 'actions/checkout@v7', + 'Validate that template/* are used to generate Dockerfiles', + 'Validate .github/workflows/build.yaml is up to date', + 'Validate the README banner is up to date', + ], array_keys($this->steps(['job' => $job], 'job'))); + self::assertStringStartsWith( + 'docker run --rm -v $PWD:/app -w /app/ci webdevops/php:8.4-alpine composer install --no-dev' . "\n", + $this->steps(['job' => $job], 'job')['Validate .github/workflows/build.yaml is up to date']['run'], + 'generating needs no dev tools', + ); + } + + public function testCodeCheckRunsGrumphp(): void + { + $job = new GithubJobBuilder(self::ROOT)->getCodeCheckConfig(); + $steps = $this->steps(['job' => $job], 'job'); + + self::assertArrayNotHasKey('needs', $job); + self::assertSame(['actions/checkout@v7', 'Run GrumPHP'], array_keys($steps)); + self::assertStringEndsWith("vendor/bin/grumphp run --no-interaction'", $steps['Run GrumPHP']['run']); + } + + /** + * Jobs of the fixture's webdevops/base:alpine, as FileReader and GithubCommand describe it. + * + * @param array $node replaces values of the node + * @return array> + */ + private function jobs(array $node = []): array + { + $node = [ + 'id' => 'webdevops/base:alpine', + 'name' => 'webdevops/base:alpine', + 'image' => 'base', + 'tag' => 'alpine', + 'aliases' => [], + 'file' => 'docker/base/alpine/Dockerfile', + 'parent' => 'webdevops/toolbox:latest', + 'imageParent' => 0, + 'imageParentRef' => null, + 'imageDependencies' => [], + 'baseImage' => 'alpine:3.22', + 'serverspec' => ['DOCKER_IMAGE' => 'webdevops/base:alpine', 'DOCKER_TAG' => 'alpine', 'OS_FAMILY' => 'alpine'], + 'level' => 2, + 'hasChildren' => false, + ...$node, + ]; + return new GithubJobBuilder(self::ROOT)->getJobsDescription($node); + } + + /** + * The steps of a job by name, or by action for steps without a name. + * + * @param array> $jobs + * @return array> + */ + private function steps(array $jobs, string $job = 'base_alpine'): array + { + $steps = []; + foreach ($jobs[$job]['steps'] as $step) { + $steps[$step['name'] ?? $step['uses']] = $step; + } + + return $steps; + } + + /** + * @param array> $jobs + * @return array + */ + private function step(array $jobs, string $name): array + { + $steps = $this->steps($jobs); + self::assertArrayHasKey($name, $steps); + return $steps[$name]; + } +} diff --git a/ci/tests/ReadmeBanner/BannerGeneratorTest.php b/ci/tests/ReadmeBanner/BannerGeneratorTest.php new file mode 100644 index 000000000..91264e4e3 --- /dev/null +++ b/ci/tests/ReadmeBanner/BannerGeneratorTest.php @@ -0,0 +1,76 @@ +repository = new TemporaryRepository(); + } + + protected function tearDown(): void + { + $this->repository->remove(); + } + + public function testWritesTheFileOfEachTheme(): void + { + $written = new BannerGenerator($this->repository->path)->generate($this->images(), ['linux/amd64']); + + self::assertSame(['docs/assets/banner-light.svg', 'docs/assets/banner-dark.svg'], $written); + self::assertStringContainsString('#f6f8fa', $this->repository->read('docs/assets/banner-light.svg')); + self::assertStringContainsString('#151b23', $this->repository->read('docs/assets/banner-dark.svg')); + self::assertStringContainsString('webdevops/php-nginx-dev:8.5-alpine', $this->repository->read('docs/assets/banner-dark.svg')); + } + + public function testEveryThemeNeedsExactlyOneFile(): void + { + $config = $this->repository->read('conf/readme-banner.yml'); + $this->repository->write('conf/readme-banner.yml', str_replace(" dark: 'docs/assets/banner-dark.svg'\n", " sepia: 'docs/assets/banner-sepia.svg'\n", $config)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('conf/readme-banner.yml needs a file for each of the themes light and dark under "files".'); + new BannerGenerator($this->repository->path)->generate($this->images(), ['linux/amd64']); + } + + public function testGlyphTableNeedsEveryFont(): void + { + $table = json_decode($this->repository->read('ci/resources/readme-banner/glyphs.json'), true, 512, JSON_THROW_ON_ERROR); + unset($table['mono-medium']); + $this->repository->write('ci/resources/readme-banner/glyphs.json', (string)json_encode($table)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ci/resources/readme-banner/glyphs.json has no font "mono-medium". Build it again with ci/console readme-banner:build-glyphs'); + new BannerGenerator($this->repository->path)->generate($this->images(), ['linux/amd64']); + } + + /** + * @return list + */ + private function images(): array + { + return [ + ['image' => 'php', 'tag' => '8.5', 'from' => 'php:8.5-fpm-trixie'], + ['image' => 'php', 'tag' => '8.5-alpine', 'from' => 'php:8.5-fpm-alpine'], + ['image' => 'php-nginx-dev', 'tag' => '8.5-alpine', 'from' => 'webdevops/php-nginx:8.5-alpine'], + ]; + } +} diff --git a/ci/tests/ReadmeBanner/BannerRendererTest.php b/ci/tests/ReadmeBanner/BannerRendererTest.php new file mode 100644 index 000000000..9151f5026 --- /dev/null +++ b/ci/tests/ReadmeBanner/BannerRendererTest.php @@ -0,0 +1,170 @@ + */ + private static array $fonts = []; + + public static function setUpBeforeClass(): void + { + $table = json_decode((string)file_get_contents(TemporaryRepository::GLYPHS), true, 512, JSON_THROW_ON_ERROR); + foreach ($table as $name => $font) { + self::$fonts[$name] = GlyphFont::fromTable($name, $font); + } + } + + public function testRendersAnSvgWithTitleAndDescription(): void + { + $svg = $this->render('light'); + $xml = new SimpleXMLElement($svg); + + self::assertSame('1280', (string)$xml['width']); + // The base OS card is the highest: two rows of chips with the two release notes between them + self::assertSame('574', (string)$xml['height']); + self::assertSame('Test banner', (string)$xml->title); + self::assertSame( + 'Image tag anatomy using webdevops/php-nginx-dev:8.5-alpine as the example. ' + . 'Web server: php (PHP-FPM) or php-nginx (+ Nginx). Variant: no suffix for production, -dev for development. ' + . 'PHP version: 8.4 or 8.5. Base OS: no suffix for Debian (bookworm: 8.4; trixie: 8.5), -alpine for Alpine. ' + . 'Extends the official php images. linux/amd64 + linux/arm64.', + (string)$xml->desc, + ); + self::assertStringEndsWith("\n", $svg); + } + + public function testOneReleaseIsPartOfTheText(): void + { + $matrix = new ImageMatrix([ + ...$this->images(), + ['image' => 'php', 'tag' => '8.5', 'from' => 'php:8.5-fpm-bookworm'], + ], self::PLATFORMS); + + self::assertStringContainsString('Base OS: no suffix for Debian bookworm, -alpine for Alpine.', $this->render('light', matrix: $matrix)); + } + + public function testThemesUseTheirColors(): void + { + self::assertStringContainsString('fill="' . BannerRenderer::THEMES['light']['background'] . '"', $this->render('light')); + self::assertStringContainsString('fill="' . BannerRenderer::THEMES['dark']['background'] . '"', $this->render('dark')); + self::assertStringNotContainsString(BannerRenderer::THEMES['dark']['background'], $this->render('light')); + } + + public function testDrawsEveryTextAsPath(): void + { + $xml = new SimpleXMLElement($this->render('dark')); + $xml->registerXPathNamespace('svg', 'http://www.w3.org/2000/svg'); + + self::assertSame([], $xml->xpath('//svg:text')); + self::assertGreaterThan(30, count($xml->xpath('//svg:path') ?: [])); + } + + public function testRenderingIsTheSameEveryTime(): void + { + self::assertSame($this->render('light'), $this->render('light')); + } + + /** + * @return iterable, 1: string}> + */ + public static function invalidTexts(): iterable + { + yield 'flavor without description' => [ + ['flavors' => ['php' => 'PHP-FPM']], + 'The README banner has no description for the image "php-nginx". Add one under "flavors" in conf/readme-banner.yml.', + ]; + yield 'suffix without description' => [ + ['bases' => ['default' => 'Debian {release}', 'releases' => '{release}: {versions}', 'suffixes' => []]], + 'The README banner has no description for the tag suffix "-alpine". Add one under "bases: suffixes" in conf/readme-banner.yml.', + ]; + yield 'example image that is not built' => [ + ['example' => ['flavor' => 'php-apache', 'base' => 'alpine']], + 'The README banner example uses "php-apache", which is not built. Change "example" in conf/readme-banner.yml.', + ]; + yield 'example without dev image' => [ + ['example' => ['flavor' => 'php', 'base' => 'alpine']], + 'The README banner example needs webdevops/php-dev images with the tag suffix "-alpine". Change "example" in conf/readme-banner.yml.', + ]; + yield 'example without suffix' => [ + ['example' => ['flavor' => 'php-nginx', 'base' => '']], + 'The README banner example needs webdevops/php-nginx-dev images with the tag suffix "-". Change "example" in conf/readme-banner.yml.', + ]; + yield 'missing setting' => [ + ['cards' => ['flavor' => 'WEB SERVER']], + 'conf/readme-banner.yml has no setting "cards: variant".', + ]; + yield 'setting that is no text' => [ + ['title' => ['a', 'b']], + 'The setting "title" in conf/readme-banner.yml must be a text.', + ]; + yield 'text that does not fit' => [ + ['footer' => [str_repeat('Rebuilt nightly ', 20)]], + 'The README banner has no room for the footer (', + ]; + yield 'character without glyph' => [ + ['heading' => 'PICK YOUR IMAGE ✓'], + 'The banner font "sans-semibold" has no glyph for "✓"', + ]; + } + + /** + * @param array $texts + */ + #[DataProvider('invalidTexts')] + public function testInvalidTextsAreReported(array $texts, string $message): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($message); + $this->render('light', $texts); + } + + /** + * @param array $texts replaces settings of the fixture's conf/readme-banner.yml + */ + private function render(string $theme, array $texts = [], ?ImageMatrix $matrix = null): string + { + $texts = [...Yaml::parseFile(TemporaryRepository::FIXTURE . '/conf/readme-banner.yml'), ...$texts]; + return new BannerRenderer($matrix ?? new ImageMatrix($this->images(), self::PLATFORMS), $texts, self::$fonts)->render($theme); + } + + /** + * The PHP images of the fixture repository: 8.4 on bookworm, 8.5 on trixie and alpine. + * + * @return list + */ + private function images(): array + { + return [ + ['image' => 'php', 'tag' => '8.4', 'from' => 'php:8.4-fpm-bookworm'], + ['image' => 'php', 'tag' => '8.5', 'from' => 'php:8.5-fpm-trixie'], + ['image' => 'php', 'tag' => '8.5-alpine', 'from' => 'php:8.5-fpm-alpine'], + ['image' => 'php-nginx', 'tag' => '8.5-alpine', 'from' => 'webdevops/php:8.5-alpine'], + ['image' => 'php-nginx-dev', 'tag' => '8.5-alpine', 'from' => 'webdevops/php-nginx:8.5-alpine'], + ['image' => 'base', 'tag' => 'ubuntu-22.04', 'from' => 'ubuntu:22.04'], + ]; + } +} diff --git a/ci/tests/ReadmeBanner/GlyphFontTest.php b/ci/tests/ReadmeBanner/GlyphFontTest.php new file mode 100644 index 000000000..2f189ec09 --- /dev/null +++ b/ci/tests/ReadmeBanner/GlyphFontTest.php @@ -0,0 +1,69 @@ +font(); + + self::assertSame(0.0, $font->width('', 10)); + self::assertSame(6.0, $font->width('A', 10)); + self::assertSame((600 - 50 + 600) / 100, $font->width('AV', 10)); + self::assertSame(12.0, $font->width('VA', 10), 'only AV kerns'); + self::assertSame((600 - 50 + 600 + 250) / 100 + 2 * 1.5, $font->width('AV ', 10, 1.5)); + } + + public function testPathPlacesEachGlyphOnTheBaseline(): void + { + // y points down in SVG, so the outline is flipped; the space has no outline + self::assertSame( + 'M 10 100 L 16 100 L 13 93 Z M 15.5 93 L 18.5 100 L 21.5 93 Z M 24 100 L 30 100 L 27 93 Z', + $this->font()->path('AV A', 10, 100, 10), + ); + } + + public function testPathWithTracking(): void + { + self::assertSame('M 10 100 L 16 100 L 13 93 Z M 18.5 93 L 21.5 100 L 24.5 93 Z', $this->font()->path('AV', 10, 100, 10, 3)); + } + + public function testPathAnchoredInTheMiddleOrAtTheEnd(): void + { + $font = $this->font(); + + self::assertSame($font->path('A', 7, 100, 10), $font->path('A', 10, 100, 10, 0, 'middle')); + self::assertSame($font->path('A', 4, 100, 10), $font->path('A', 10, 100, 10, 0, 'end')); + } + + public function testMissingCharacterIsAnError(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The banner font "test" has no glyph for "ä" (U+00E4) in "Bär".'); + $this->font()->width('Bär', 10); + } + + private function font(): GlyphFont + { + return GlyphFont::fromTable('test', [ + 'unitsPerEm' => 1000, + 'glyphs' => [ + 'A' => [600, 'M 0 0 L 600 0 L 300 700 Z'], + 'V' => [600, 'M 0 700 L 300 0 L 600 700 Z'], + 'B' => [600, 'M 0 0 L 600 0 L 600 700 Z'], + 'r' => [400, 'M 0 0 L 400 0 L 400 500 Z'], + ' ' => [250, ''], + ], + 'kerning' => ['AV' => -50], + ]); + } +} diff --git a/ci/tests/ReadmeBanner/Glyphs/BinaryDataTest.php b/ci/tests/ReadmeBanner/Glyphs/BinaryDataTest.php new file mode 100644 index 000000000..e2ff525fe --- /dev/null +++ b/ci/tests/ReadmeBanner/Glyphs/BinaryDataTest.php @@ -0,0 +1,81 @@ +uint8(0)); + self::assertSame(255, $data->uint8(2)); + self::assertSame(-1, $data->int8(2)); + self::assertSame(127, $data->int8(6)); + self::assertSame(0x0102, $data->uint16(0)); + self::assertSame(0xFFFE, $data->uint16(2)); + self::assertSame(-2, $data->int16(2)); + self::assertSame(-32768, $data->int16(4)); + self::assertSame(32767, $data->int16(6)); + self::assertSame(0x0102FFFE, $data->uint32(0)); + self::assertSame(0x80007FFF, $data->uint32(4)); + } + + /** + * @return iterable + */ + public static function f2dot14(): iterable + { + yield 'one' => ["\x40\x00", 1.0]; + yield 'half' => ["\x20\x00", 0.5]; + yield 'minus two' => ["\x80\x00", -2.0]; + yield 'almost two' => ["\x7F\xFF", 32767 / 16384]; + } + + #[DataProvider('f2dot14')] + public function testReadsFixedPointNumbers(string $bytes, float $expected): void + { + self::assertSame($expected, new BinaryData($bytes)->f2dot14(0)); + } + + public function testReadsTagsAndSlices(): void + { + $data = new BinaryData('headglyfloca'); + + self::assertSame('glyf', $data->tag(4)); + self::assertSame('loca', $data->slice(4, 8)->tag(4)); + } + + /** + * @return iterable + */ + public static function readsPastTheEnd(): iterable + { + yield 'uint8' => [fn (BinaryData $data): int => $data->uint8(4), 5]; + yield 'uint16' => [fn (BinaryData $data): int => $data->uint16(3), 5]; + yield 'uint32' => [fn (BinaryData $data): int => $data->uint32(2), 6]; + yield 'tag' => [fn (BinaryData $data): string => $data->tag(1), 5]; + yield 'slice' => [fn (BinaryData $data): BinaryData => $data->slice(2, 3), 5]; + yield 'negative offset' => [fn (BinaryData $data): int => $data->uint8(-1), 0]; + } + + /** + * @param callable(BinaryData): mixed $read + */ + #[DataProvider('readsPastTheEnd')] + public function testReadingPastTheEndIsAnError(callable $read, int $end): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The font file is damaged: it ends before byte ' . $end . '.'); + $read(new BinaryData("\0\0\0\0")); + } +} diff --git a/ci/tests/ReadmeBanner/Glyphs/FontBuilder.php b/ci/tests/ReadmeBanner/Glyphs/FontBuilder.php new file mode 100644 index 000000000..bcbd56b6a --- /dev/null +++ b/ci/tests/ReadmeBanner/Glyphs/FontBuilder.php @@ -0,0 +1,330 @@ + */ + private array $glyphs = [['advance' => 500, 'leftSideBearing' => 0, 'data' => '']]; + + /** @var array glyph per code point */ + private array $cmap = []; + + private int $cmapFormat = self::CMAP_FORMAT_4; + + private ?string $rawCmap = null; + + private bool $longLoca = false; + + private ?int $numberOfHMetrics = null; + + private string $version = "\x00\x01\x00\x00"; + + /** @var array extra tables, null leaves out a required table */ + private array $tables = []; + + public function __construct(private readonly int $unitsPerEm = 1000) + { + } + + /** + * A glyph with quadratic contours. Each point is x, y and optionally false for an + * off-curve point or "cubic" for an off-curve point with the cubic flag. + * + * @param list> $contours + */ + public function simpleGlyph(int $advance, array $contours, ?int $leftSideBearing = null): int + { + $points = array_merge(...$contours); + $xs = array_column($points, 0); + $ys = array_column($points, 1); + $xMin = $xs === [] ? 0 : min($xs); + $data = OpenType::int16(count($contours), $xMin, $ys === [] ? 0 : min($ys), $xs === [] ? 0 : max($xs), $ys === [] ? 0 : max($ys)); + $end = -1; + foreach ($contours as $contour) { + $end += count($contour); + $data .= OpenType::uint16($end); + } + + $data .= OpenType::uint16(0); // no instructions + + $flags = []; + $xBytes = ''; + $yBytes = ''; + $previousX = 0; + $previousY = 0; + foreach ($points as $point) { + $type = $point[2] ?? true; + $flag = $type === true ? self::ON_CURVE : ($type === 'cubic' ? self::CUBIC : 0); + [$xFlag, $bytes] = $this->coordinate($point[0] - $previousX, self::X_SHORT, self::X_SAME_OR_POSITIVE); + $xBytes .= $bytes; + [$yFlag, $bytes] = $this->coordinate($point[1] - $previousY, self::Y_SHORT, self::Y_SAME_OR_POSITIVE); + $yBytes .= $bytes; + $flags[] = $flag | $xFlag | $yFlag; + [$previousX, $previousY] = $point; + } + + // Runs of the same flag are written once with a repeat count + $i = 0; + while ($i < count($flags)) { + $run = 1; + while ($i + $run < count($flags) && $flags[$i + $run] === $flags[$i] && $run < 256) { + $run++; + } + + $data .= $run > 1 ? chr($flags[$i] | self::REPEAT) . chr($run - 1) : chr($flags[$i]); + $i += $run; + } + + $this->glyphs[] = ['advance' => $advance, 'leftSideBearing' => $leftSideBearing ?? $xMin, 'data' => $data . $xBytes . $yBytes]; + return count($this->glyphs) - 1; + } + + /** + * A glyph made of other glyphs, each moved by dx/dy and optionally scaled: + * one scale, an x and a y scale, or a 2x2 matrix xx, xy, yx, yy. + * + * @param list, matchPoints?: bool}> $components + */ + public function compositeGlyph(int $advance, array $components): int + { + $data = OpenType::int16(-1, 0, 0, 0, 0); + foreach ($components as $i => $component) { + $words = max(abs($component['dx']), abs($component['dy'])) > 127; + $scale = $component['scale'] ?? []; + $scale = is_array($scale) ? $scale : [$scale]; + $flags = ($component['matchPoints'] ?? false) ? 0 : 0x0002; + $flags |= $words ? 0x0001 : 0; + $flags |= [0 => 0, 1 => 0x0008, 2 => 0x0040, 4 => 0x0080][count($scale)]; + $flags |= $i < count($components) - 1 ? 0x0020 : 0; + $data .= OpenType::uint16($flags, $component['glyph']); + $data .= $words ? OpenType::int16($component['dx'], $component['dy']) : OpenType::int8($component['dx'], $component['dy']); + foreach ($scale as $value) { + $data .= OpenType::int16((int)round($value * 16384)); + } + } + + $this->glyphs[] = ['advance' => $advance, 'leftSideBearing' => 0, 'data' => $data]; + return count($this->glyphs) - 1; + } + + public function emptyGlyph(int $advance): int + { + $this->glyphs[] = ['advance' => $advance, 'leftSideBearing' => 0, 'data' => '']; + return count($this->glyphs) - 1; + } + + public function map(int $codePoint, int $glyph): self + { + $this->cmap[$codePoint] = $glyph; + return $this; + } + + public function cmapFormat(int $format): self + { + $this->cmapFormat = $format; + return $this; + } + + /** + * A cmap with one subtable, instead of the one built from map(). + */ + public function rawCmap(int $platform, int $encoding, string $subtable): self + { + $this->rawCmap = OpenType::uint16(0, 1, $platform, $encoding) . OpenType::uint32(12) . $subtable; + return $this; + } + + public function longLoca(): self + { + $this->longLoca = true; + return $this; + } + + public function numberOfHMetrics(int $count): self + { + $this->numberOfHMetrics = $count; + return $this; + } + + public function version(string $version): self + { + $this->version = $version; + return $this; + } + + /** + * Adds a table, or leaves out a required one with null. + */ + public function table(string $tag, ?string $data): self + { + $this->tables[$tag] = $data; + return $this; + } + + public function build(): string + { + $glyf = ''; + $offsets = []; + foreach ($this->glyphs as $glyph) { + $offsets[] = strlen($glyf); + $glyf .= $glyph['data'] . str_repeat("\0", (4 - strlen($glyph['data']) % 4) % 4); + } + + $offsets[] = strlen($glyf); + $loca = ''; + foreach ($offsets as $offset) { + $loca .= $this->longLoca ? OpenType::uint32($offset) : OpenType::uint16(intdiv($offset, 2)); + } + + $numberOfHMetrics = $this->numberOfHMetrics ?? count($this->glyphs); + $hmtx = ''; + foreach ($this->glyphs as $i => $glyph) { + $hmtx .= ($i < $numberOfHMetrics ? OpenType::uint16($glyph['advance']) : '') . OpenType::int16($glyph['leftSideBearing']); + } + + $tables = [ + 'cmap' => $this->rawCmap ?? $this->cmap(), + 'glyf' => $glyf, + // version, revision, checksum adjustment, magic number, flags, unitsPerEm, dates, bounding box, + // macStyle, lowestRecPPEM, fontDirectionHint, indexToLocFormat, glyphDataFormat + 'head' => OpenType::uint32(0x00010000, 0, 0, 0x5F0F3CF5) . OpenType::uint16(0, $this->unitsPerEm) + . str_repeat("\0", 24) . OpenType::uint16(0, 8, 2, $this->longLoca ? 1 : 0, 0), + 'hhea' => OpenType::uint32(0x00010000) . str_repeat("\0", 30) . OpenType::uint16($numberOfHMetrics), + 'hmtx' => $hmtx, + 'loca' => $loca, + 'maxp' => OpenType::uint32(0x00005000) . OpenType::uint16(count($this->glyphs)), + ]; + foreach ($this->tables as $tag => $data) { + if ($data === null) { + unset($tables[$tag]); + } else { + $tables[$tag] = $data; + } + } + + ksort($tables); + $directory = $this->version . OpenType::uint16(count($tables), 0, 0, 0); + $offset = 12 + 16 * count($tables); + $data = ''; + foreach ($tables as $tag => $table) { + $directory .= str_pad((string)$tag, 4) . OpenType::uint32(0, $offset + strlen($data), strlen($table)); + $data .= $table . str_repeat("\0", (4 - strlen($table) % 4) % 4); + } + + return $directory . $data; + } + + /** + * Writes the font to a temporary file and returns its path. + */ + public function save(): string + { + $path = (string)tempnam(sys_get_temp_dir(), 'font-'); + file_put_contents($path, $this->build()); + return $path; + } + + private function cmap(): string + { + $codePoints = array_keys($this->cmap); + sort($codePoints); + if ($this->cmapFormat === self::CMAP_FORMAT_12) { + $groups = ''; + foreach ($codePoints as $codePoint) { + $groups .= OpenType::uint32($codePoint, $codePoint, $this->cmap[$codePoint]); + } + + $subtable = OpenType::uint16(12, 0) . OpenType::uint32(16 + strlen($groups), 0, count($codePoints)) . $groups; + return OpenType::uint16(0, 1, 3, 10) . OpenType::uint32(12) . $subtable; + } + + // One segment per code point and the closing segment at 0xFFFF + $segments = count($codePoints) + 1; + $glyphArray = $this->cmapFormat === self::CMAP_FORMAT_4_GLYPH_ARRAY; + $ends = ''; + $starts = ''; + $deltas = ''; + $rangeOffsets = ''; + $glyphIds = ''; + foreach ($codePoints as $s => $codePoint) { + $ends .= OpenType::uint16($codePoint); + $starts .= OpenType::uint16($codePoint); + // With the glyph array, idDelta is added to the glyph from the array + $deltas .= OpenType::uint16($glyphArray ? -1 : $this->cmap[$codePoint] - $codePoint); + // From the segment's own idRangeOffset field to its entry in the glyph array after all of them + $rangeOffsets .= OpenType::uint16($glyphArray ? 2 * ($segments - $s) + 2 * $s : 0); + $glyphIds .= $glyphArray ? OpenType::uint16($this->cmap[$codePoint] + 1) : ''; + } + + $ends .= OpenType::uint16(0xFFFF); + $starts .= OpenType::uint16(0xFFFF); + $deltas .= OpenType::uint16(1); + $rangeOffsets .= OpenType::uint16(0); + $body = OpenType::uint16(0, 2 * $segments, 0, 0, 0) . $ends . OpenType::uint16(0) . $starts . $deltas . $rangeOffsets . $glyphIds; + $subtable = OpenType::uint16(4, 4 + strlen($body)) . $body; + return OpenType::uint16(0, 1, 3, 1) . OpenType::uint32(12) . $subtable; + } + + /** + * @return array{0: int, 1: string} flag bits and bytes of a coordinate delta, as short as possible + */ + private function coordinate(int $delta, int $short, int $sameOrPositive): array + { + if ($delta === 0) { + return [$sameOrPositive, '']; + } + + if (abs($delta) < 256) { + return [$short | ($delta > 0 ? $sameOrPositive : 0), chr(abs($delta))]; + } + + return [0, OpenType::int16($delta)]; + } +} diff --git a/ci/tests/ReadmeBanner/Glyphs/GlyphTableBuilderTest.php b/ci/tests/ReadmeBanner/Glyphs/GlyphTableBuilderTest.php new file mode 100644 index 000000000..7fb9a9132 --- /dev/null +++ b/ci/tests/ReadmeBanner/Glyphs/GlyphTableBuilderTest.php @@ -0,0 +1,118 @@ + */ + private array $files = []; + + protected function tearDown(): void + { + foreach ($this->files as $file) { + @unlink($file); + } + } + + public function testCharactersArePrintableAsciiAndSomeTypography(): void + { + $characters = GlyphTableBuilder::characters(); + + self::assertCount(95 + 8, $characters); + self::assertSame(' ', $characters[0]); + self::assertSame('~', $characters[94]); + self::assertSame('·–—…‘’“”', implode('', array_map(fn (int $i): string => $characters[$i], [95, 96, 97, 98, 99, 100, 101, 102]))); + } + + public function testWritesOneTableForEachFont(): void + { + $fonts = array_combine(GlyphFont::FONTS, array_map(fn (string $key): string => $this->font(), GlyphFont::FONTS)); + + $json = new GlyphTableBuilder()->build($fonts); + $table = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(GlyphFont::FONTS, array_keys($table)); + self::assertSame('JetBrains Mono, SIL Open Font License 1.1, see OFL-JetBrainsMono.txt', $table['mono-bold']['source']); + self::assertSame('Inter, SIL Open Font License 1.1, see OFL-Inter.txt', $table['sans-regular']['source']); + self::assertSame(2048, $table['sans-regular']['unitsPerEm']); + self::assertSame(GlyphTableBuilder::characters(), array_map(strval(...), array_keys($table['sans-regular']['glyphs']))); + self::assertSame([600, 'M 100 0 L 500 0 L 500 700 L 100 700 Z'], $table['sans-regular']['glyphs']['A']); + self::assertSame([250, ''], $table['sans-regular']['glyphs'][' ']); + self::assertSame(['AV' => -80, 'VA' => -60], $table['sans-regular']['kerning']); + self::assertStringContainsString("\n \"A\": [600, \"M 100 0 L 500 0 L 500 700 L 100 700 Z\"],\n", $json, 'one glyph per line'); + self::assertStringContainsString('"“": [250, ""]', $json, 'Unicode is not escaped'); + + // GlyphFont reads the table back + $font = GlyphFont::fromTable('sans-regular', $table['sans-regular']); + self::assertSame((600 - 80 + 600) / 2048 * 10, $font->width('AV', 10)); + } + + public function testEveryFontIsNeeded(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The README banner draws with the fonts mono-bold, mono-medium, sans-regular, sans-medium, sans-semibold. Add mono-medium=, sans-semibold=.'); + new GlyphTableBuilder()->build(['mono-bold' => 'a.ttf', 'sans-regular' => 'b.ttf', 'sans-medium' => 'c.ttf']); + } + + public function testFontKeyNamesTheFamily(): void + { + $fonts = array_fill_keys(GlyphFont::FONTS, 'font.ttf'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The font key "serif-bold" has to start with "mono-" or "sans-".'); + new GlyphTableBuilder()->build(['serif-bold' => 'font.ttf', ...$fonts]); + } + + public function testEveryCharacterNeedsAGlyph(): void + { + $path = $this->font(withoutCharacter: '~'); + $fonts = array_fill_keys(GlyphFont::FONTS, $path); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' has no glyph for "~".'); + new GlyphTableBuilder()->build($fonts); + } + + /** + * A font with a square for A and V, which kern, and an empty glyph for every other character. + */ + private function font(?string $withoutCharacter = null): string + { + $builder = new FontBuilder(2048); + $empty = $builder->emptyGlyph(250); + $a = $builder->simpleGlyph(600, [[[100, 0], [500, 0], [500, 700], [100, 700]]]); + $v = $builder->simpleGlyph(600, [[[100, 0], [500, 0], [500, 700], [100, 700]]]); + foreach (GlyphTableBuilder::characters() as $character) { + if ($character !== $withoutCharacter) { + $builder->map(mb_ord($character), ['A' => $a, 'V' => $v][$character] ?? $empty); + } + } + + $builder->table('GPOS', OpenType::gpos( + ['latn' => OpenType::script(OpenType::langSys([0]))], + [['kern', [0]]], + [OpenType::lookup(2, [OpenType::pairPosFormat1([$a => [$v => [-80, 0]], $v => [$a => [-60, 0]]])])], + )); + return $this->files[] = $builder->save(); + } +} diff --git a/ci/tests/ReadmeBanner/Glyphs/GposKerningTest.php b/ci/tests/ReadmeBanner/Glyphs/GposKerningTest.php new file mode 100644 index 000000000..e905c907d --- /dev/null +++ b/ci/tests/ReadmeBanner/Glyphs/GposKerningTest.php @@ -0,0 +1,269 @@ +kerning([OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-80, 0]]])])]); + + self::assertSame(-80, $kerning->adjustment(self::A, self::V, true)); + self::assertSame(0, $kerning->adjustment(self::V, self::A, true)); + self::assertSame(0, $kerning->adjustment(self::A, self::B, true)); + } + + public function testOnlyXAdvanceCountsForBothGlyphs(): void + { + $format1 = OpenType::X_PLACEMENT | OpenType::Y_PLACEMENT | OpenType::X_ADVANCE | OpenType::Y_ADVANCE; + $pairPos = OpenType::pairPosFormat1([self::A => [self::V => [-80, 30]]], $format1, OpenType::X_PLACEMENT | OpenType::X_ADVANCE); + + self::assertSame(-50, $this->kerning([OpenType::lookup(self::PAIR, [$pairPos])])->adjustment(self::A, self::V, true)); + } + + public function testValueRecordWithoutXAdvanceChangesNothing(): void + { + $pairPos = OpenType::pairPosFormat1([self::A => [self::V => [0, 0]]], OpenType::X_PLACEMENT, OpenType::Y_ADVANCE); + + self::assertSame(0, $this->kerning([OpenType::lookup(self::PAIR, [$pairPos])])->adjustment(self::A, self::V, true)); + } + + public function testPairAdjustmentByGlyphClass(): void + { + $pairPos = OpenType::pairPosFormat2( + OpenType::coverageFormat2([self::A, self::B]), + OpenType::classDefFormat2([self::A, self::B, 1]), + OpenType::classDefFormat1(self::V, 1), + [[[0, 0], [0, 0]], [[0, 0], [-50, -5]]], + OpenType::X_ADVANCE, + OpenType::X_ADVANCE, + ); + $kerning = $this->kerning([OpenType::lookup(self::PAIR, [$pairPos])]); + + self::assertSame(-55, $kerning->adjustment(self::A, self::V, true)); + self::assertSame(-55, $kerning->adjustment(self::B, self::V, true)); + self::assertSame(0, $kerning->adjustment(self::A, self::B, true), 'B has class 0 as second glyph'); + } + + public function testClassOutsideTheClassCountsChangesNothing(): void + { + $pairPos = OpenType::pairPosFormat2( + OpenType::coverageFormat1(self::A), + OpenType::classDefFormat1(self::A, 5), + OpenType::classDefFormat1(self::V, 1), + [[[0, 0], [-50, 0]], [[0, 0], [-50, 0]]], + ); + + self::assertSame(0, $this->kerning([OpenType::lookup(self::PAIR, [$pairPos])])->adjustment(self::A, self::V, true)); + } + + public function testSingleAdjustmentAppliesToEachGlyph(): void + { + $kerning = $this->kerning([OpenType::lookup(self::SINGLE, [OpenType::singlePosFormat1(OpenType::coverageFormat1(self::A, self::V), 20)])]); + + self::assertSame(40, $kerning->adjustment(self::A, self::V, true)); + self::assertSame(20, $kerning->adjustment(self::B, self::V, true)); + self::assertSame(0, $kerning->adjustment(self::B, self::B, true)); + } + + public function testSingleAdjustmentWithAValuePerGlyph(): void + { + $singlePos = OpenType::singlePosFormat2( + OpenType::coverageFormat1(self::A, self::V), + [7, -3], + OpenType::X_PLACEMENT | OpenType::X_ADVANCE, + ); + + self::assertSame(4, $this->kerning([OpenType::lookup(self::SINGLE, [$singlePos])])->adjustment(self::A, self::V, true)); + } + + public function testFirstMatchingSubtableOfALookupWins(): void + { + $lookup = OpenType::lookup(self::PAIR, [ + OpenType::pairPosFormat1([self::B => [self::V => [-90, 0]]]), + OpenType::pairPosFormat1([self::A => [self::V => [-30, 0]]]), + OpenType::pairPosFormat1([self::A => [self::V => [-60, 0]]]), + ]); + + self::assertSame(-30, $this->kerning([$lookup])->adjustment(self::A, self::V, true)); + } + + public function testEveryLookupOfTheFeatureApplies(): void + { + $kerning = $this->kerning([ + OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-30, 0]]])]), + OpenType::lookup(self::SINGLE, [OpenType::singlePosFormat1(OpenType::coverageFormat1(self::V), 5)]), + ]); + + self::assertSame(-25, $kerning->adjustment(self::A, self::V, true)); + } + + public function testExtensionLookupPointsToTheRealSubtable(): void + { + $lookup = OpenType::lookup(9, [OpenType::extension(self::PAIR, OpenType::pairPosFormat1([self::A => [self::V => [-70, 0]]]))]); + + self::assertSame(-70, $this->kerning([$lookup])->adjustment(self::A, self::V, true)); + } + + public function testOtherLookupTypesAreRejected(): void + { + $kerning = $this->kerning([OpenType::lookup(4, [OpenType::coverageFormat1(self::A)])]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('test.ttf kerns with GPOS lookups of type 4; only single (1) and pair adjustments (2) are supported.'); + $kerning->adjustment(self::A, self::V, true); + } + + public function testOnlyTheKernAndDistFeaturesApply(): void + { + $gpos = OpenType::gpos( + ['DFLT' => OpenType::script(OpenType::langSys([0, 1, 2]))], + [['kern', [0]], ['liga', [1]], ['dist', [2]]], + [ + OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-10, 0]]])]), + OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-200, 0]]])]), + OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-3, 0]]])]), + ], + ); + + self::assertSame(-13, new GposKerning(new BinaryData($gpos), null, 'test.ttf')->adjustment(self::A, self::V, false)); + } + + public function testRequiredFeatureApplies(): void + { + $gpos = OpenType::gpos( + ['DFLT' => OpenType::script(OpenType::langSys([], 0))], + [['kern', [0]]], + [OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-10, 0]]])])], + ); + + self::assertSame(-10, new GposKerning(new BinaryData($gpos), null, 'test.ttf')->adjustment(self::A, self::V, false)); + } + + /** + * @return iterable, 1: bool, 2: int}> + */ + public static function scripts(): iterable + { + // Feature 0 has the lookup with -80, feature 1 the one with -10 + $latinLangSys = OpenType::langSys([0]); + $defaultLangSys = OpenType::langSys([1]); + $latin = OpenType::script($latinLangSys); + $default = OpenType::script($defaultLangSys); + yield 'Latin pair uses latn' => [['DFLT' => $default, 'latn' => $latin], true, -80]; + yield 'other pair uses DFLT' => [['DFLT' => $default, 'latn' => $latin], false, -10]; + yield 'Latin pair falls back to DFLT' => [['DFLT' => $default], true, -10]; + yield 'other pair falls back to latn' => [['latn' => $latin], false, -80]; + yield 'dflt is a default script too' => [['dflt' => $default, 'cyrl' => $latin], false, -10]; + yield 'no matching script' => [['cyrl' => $latin], true, 0]; + yield 'language system named dflt' => [['latn' => OpenType::script(null, ['DEU ' => $defaultLangSys, 'dflt' => $latinLangSys])], true, -80]; + yield 'no default language system' => [['latn' => OpenType::script(null, ['DEU ' => $latinLangSys])], true, 0]; + } + + /** + * @param array $scripts + */ + #[DataProvider('scripts')] + public function testScriptOfThePair(array $scripts, bool $latin, int $expected): void + { + $gpos = OpenType::gpos( + $scripts, + [['kern', [0]], ['kern', [1]]], + [ + OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-80, 0]]])]), + OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-10, 0]]])]), + ], + ); + $kerning = new GposKerning(new BinaryData($gpos), null, 'test.ttf'); + + self::assertSame($expected, $kerning->adjustment(self::A, self::V, $latin)); + self::assertSame($expected, $kerning->adjustment(self::A, self::V, $latin), 'the second call reads the cached lookups'); + } + + /** + * @return iterable + */ + public static function lookupFlags(): iterable + { + // V is a mark (class 3) with mark attachment class 2, A a base glyph (class 1), B a ligature (class 2) + yield 'no flag' => [0, null, 'AV', -80]; + yield 'ignore marks' => [self::IGNORE_MARKS, null, 'AV', 0]; + yield 'ignore marks keeps base glyphs' => [self::IGNORE_MARKS, null, 'AB', -40]; + yield 'ignore base glyphs' => [self::IGNORE_BASE_GLYPHS, null, 'AV', 0]; + yield 'ignore ligatures' => [self::IGNORE_LIGATURES, null, 'AB', 0]; + yield 'ignore ligatures keeps marks' => [self::IGNORE_LIGATURES, null, 'AV', -80]; + yield 'mark in the filtering set' => [self::USE_MARK_FILTERING_SET, 0, 'AV', -80]; + yield 'mark not in the filtering set' => [self::USE_MARK_FILTERING_SET, 1, 'AV', 0]; + yield 'mark filtering set that does not exist' => [self::USE_MARK_FILTERING_SET, 7, 'AV', 0]; + yield 'mark of the attachment type' => [0x0200, null, 'AV', -80]; + yield 'mark of another attachment type' => [0x0100, null, 'AV', 0]; + } + + #[DataProvider('lookupFlags')] + public function testLookupFlagLeavesOutGlyphs(int $flag, ?int $markSet, string $pair, int $expected): void + { + $glyphs = ['A' => self::A, 'B' => self::B, 'V' => self::V]; + $gdef = OpenType::gdef( + OpenType::classDefFormat1(self::A, 1, 2, 3), + OpenType::classDefFormat1(self::V, 2), + [OpenType::coverageFormat1(self::V), OpenType::coverageFormat1(self::B)], + ); + $lookup = OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-80, 0], self::B => [-40, 0]]])], $flag, $markSet); + + self::assertSame($expected, $this->kerning([$lookup], $gdef)->adjustment($glyphs[$pair[0]], $glyphs[$pair[1]], true)); + } + + public function testSingleAdjustmentLeavesOutIgnoredGlyphs(): void + { + $gdef = OpenType::gdef(OpenType::classDefFormat2([self::V, self::V, 3])); + $lookup = OpenType::lookup(self::SINGLE, [OpenType::singlePosFormat1(OpenType::coverageFormat1(self::A, self::V), 20)], self::IGNORE_MARKS); + + self::assertSame(20, $this->kerning([$lookup], $gdef)->adjustment(self::A, self::V, true)); + } + + public function testGdefWithoutClassDefinitions(): void + { + $lookup = OpenType::lookup(self::PAIR, [OpenType::pairPosFormat1([self::A => [self::V => [-80, 0]]])], self::IGNORE_MARKS); + + self::assertSame(-80, $this->kerning([$lookup], OpenType::gdef(null))->adjustment(self::A, self::V, true)); + } + + /** + * A GPOS table with the lookups in the feature "kern" of the default script. + * + * @param list $lookups + */ + private function kerning(array $lookups, ?string $gdef = null): GposKerning + { + $gpos = OpenType::gpos(['DFLT' => OpenType::script(OpenType::langSys([0]))], [['kern', array_keys($lookups)]], $lookups); + return new GposKerning(new BinaryData($gpos), $gdef === null ? null : new BinaryData($gdef), 'test.ttf'); + } +} diff --git a/ci/tests/ReadmeBanner/Glyphs/OpenType.php b/ci/tests/ReadmeBanner/Glyphs/OpenType.php new file mode 100644 index 000000000..ea5d50dd2 --- /dev/null +++ b/ci/tests/ReadmeBanner/Glyphs/OpenType.php @@ -0,0 +1,325 @@ + $scripts script table per script tag + * @param list}> $features tag and lookup indices of each feature + * @param list $lookups + */ + public static function gpos(array $scripts, array $features, array $lookups): string + { + $scriptList = [self::uint16(count($scripts))]; + foreach ($scripts as $tag => $script) { + $scriptList[] = $tag; + $scriptList[] = self::offset16($script); + } + + $featureList = [self::uint16(count($features))]; + foreach ($features as [$tag, $lookupIndices]) { + $featureList[] = $tag; + $featureList[] = self::offset16(self::uint16(0, count($lookupIndices), ...$lookupIndices)); + } + + return self::table( + self::uint16(1, 0), + self::offset16(self::table(...$scriptList)), + self::offset16(self::table(...$featureList)), + self::offset16(self::table(self::uint16(count($lookups)), ...array_map(self::offset16(...), $lookups))), + ); + } + + /** + * @param array $langSystems language system per language tag + */ + public static function script(?string $defaultLangSys, array $langSystems = []): string + { + $parts = [self::offset16($defaultLangSys), self::uint16(count($langSystems))]; + foreach ($langSystems as $tag => $langSys) { + $parts[] = $tag; + $parts[] = self::offset16($langSys); + } + + return self::table(...$parts); + } + + /** + * @param list $featureIndices + */ + public static function langSys(array $featureIndices, int $requiredFeature = 0xFFFF): string + { + return self::uint16(0, $requiredFeature, count($featureIndices), ...$featureIndices); + } + + /** + * @param list $subtables + */ + public static function lookup(int $type, array $subtables, int $flag = 0, ?int $markSet = null): string + { + return self::table( + self::uint16($type, $flag, count($subtables)), + ...[...array_map(self::offset16(...), $subtables), $markSet === null ? '' : self::uint16($markSet)], + ); + } + + /** + * Pair adjustment with a list of second glyphs per first glyph. + * + * @param array> $pairs XAdvance of the first and the second glyph per pair + */ + public static function pairPosFormat1(array $pairs, int $format1 = self::X_ADVANCE, int $format2 = 0): string + { + ksort($pairs); + $parts = [ + self::uint16(1), + self::offset16(self::coverageFormat1(...array_keys($pairs))), + self::uint16($format1, $format2, count($pairs)), + ]; + foreach ($pairs as $seconds) { + ksort($seconds); + $pairSet = self::uint16(count($seconds)); + foreach ($seconds as $second => [$first, $other]) { + $pairSet .= self::uint16($second) . self::valueRecord($format1, $first) . self::valueRecord($format2, $other); + } + + $parts[] = self::offset16($pairSet); + } + + return self::table(...$parts); + } + + /** + * Pair adjustment by glyph classes. + * + * @param list> $values XAdvance of the first and the second glyph per first and second class + */ + public static function pairPosFormat2(string $coverage, string $classDef1, string $classDef2, array $values, int $format1 = self::X_ADVANCE, int $format2 = 0): string + { + $records = ''; + foreach ($values as $row) { + foreach ($row as [$first, $second]) { + $records .= self::valueRecord($format1, $first) . self::valueRecord($format2, $second); + } + } + + return self::table( + self::uint16(2), + self::offset16($coverage), + self::uint16($format1, $format2), + self::offset16($classDef1), + self::offset16($classDef2), + self::uint16(count($values), count($values[0] ?? [])), + $records, + ); + } + + /** + * Single adjustment with the same value for every covered glyph. + */ + public static function singlePosFormat1(string $coverage, int $xAdvance, int $format = self::X_ADVANCE): string + { + return self::table(self::uint16(1), self::offset16($coverage), self::uint16($format), self::valueRecord($format, $xAdvance)); + } + + /** + * Single adjustment with a value per covered glyph, in coverage order. + * + * @param list $xAdvances + */ + public static function singlePosFormat2(string $coverage, array $xAdvances, int $format = self::X_ADVANCE): string + { + $records = ''; + foreach ($xAdvances as $xAdvance) { + $records .= self::valueRecord($format, $xAdvance); + } + + return self::table(self::uint16(2), self::offset16($coverage), self::uint16($format, count($xAdvances)), $records); + } + + public static function extension(int $type, string $subtable): string + { + return self::table(self::uint16(1, $type), self::offset32($subtable)); + } + + public static function coverageFormat1(int ...$glyphs): string + { + return self::uint16(1, count($glyphs), ...$glyphs); + } + + /** + * @param array{0: int, 1: int} ...$ranges first and last glyph of each range + */ + public static function coverageFormat2(array ...$ranges): string + { + $bytes = self::uint16(2, count($ranges)); + $index = 0; + foreach ($ranges as [$start, $end]) { + $bytes .= self::uint16($start, $end, $index); + $index += $end - $start + 1; + } + + return $bytes; + } + + public static function classDefFormat1(int $startGlyph, int ...$classes): string + { + return self::uint16(1, $startGlyph, count($classes), ...$classes); + } + + /** + * @param array{0: int, 1: int, 2: int} ...$ranges first glyph, last glyph and class of each range + */ + public static function classDefFormat2(array ...$ranges): string + { + $bytes = self::uint16(2, count($ranges)); + foreach ($ranges as [$start, $end, $class]) { + $bytes .= self::uint16($start, $end, $class); + } + + return $bytes; + } + + /** + * The fields of $format in their order; XAdvance gets $xAdvance, the others the decoy. + */ + public static function valueRecord(int $format, int $xAdvance): string + { + $bytes = ''; + foreach ([self::X_PLACEMENT, self::Y_PLACEMENT, self::X_ADVANCE, self::Y_ADVANCE] as $field) { + if (($format & $field) !== 0) { + $bytes .= self::int16($field === self::X_ADVANCE ? $xAdvance : self::DECOY); + } + } + + return $bytes; + } + + /** + * @param list $markSets coverage of each mark glyph set; any set makes it GDEF 1.2 + */ + public static function gdef(?string $glyphClassDef, ?string $markAttachClassDef = null, array $markSets = []): string + { + $parts = [ + self::uint16(1, $markSets === [] ? 0 : 2), + self::offset16($glyphClassDef), + self::offset16(null), + self::offset16(null), + self::offset16($markAttachClassDef), + ]; + if ($markSets !== []) { + $parts[] = self::offset16(self::table(self::uint16(1, count($markSets)), ...array_map(self::offset32(...), $markSets))); + } + + return self::table(...$parts); + } +} diff --git a/ci/tests/ReadmeBanner/Glyphs/OutlinePenTest.php b/ci/tests/ReadmeBanner/Glyphs/OutlinePenTest.php new file mode 100644 index 000000000..4b1ff07fd --- /dev/null +++ b/ci/tests/ReadmeBanner/Glyphs/OutlinePenTest.php @@ -0,0 +1,61 @@ +moveTo([0.0, 0.0]); + $pen->lineTo([10.04, -0.04]); + $pen->lineTo([2.26, 7.5]); + $pen->closePath(); + + self::assertSame('M 0 0 L 10 0 L 2.3 7.5 Z', $pen->commands()); + } + + public function testCurveWithOneControlPoint(): void + { + $pen = new OutlinePen(); + $pen->qCurveTo([[0.0, 10.0], [10.0, 10.0]]); + + self::assertSame('Q 0 10 10 10', $pen->commands()); + } + + public function testCurveWithSeveralControlPointsIsSplitBetweenThem(): void + { + $pen = new OutlinePen(); + $pen->qCurveTo([[0.0, 10.0], [10.0, 10.0], [20.0, 0.0], [20.0, -10.0]]); + + self::assertSame('Q 0 10 5 10 Q 10 10 15 5 Q 20 0 20 -10', $pen->commands()); + } + + public function testCurveWithoutControlPointIsALine(): void + { + $pen = new OutlinePen(); + $pen->qCurveTo([[5.0, 5.0]]); + + self::assertSame('L 5 5', $pen->commands()); + } + + public function testClosedCurveWithoutOnCurvePointStartsBetweenItsLastAndFirstPoint(): void + { + $pen = new OutlinePen(); + $pen->qCurveTo([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], true); + + self::assertSame('M 5 5 Q 0 0 5 0 Q 10 0 10 5 Q 10 10 5 5', $pen->commands()); + } + + public function testEmptyOutline(): void + { + self::assertSame('', new OutlinePen()->commands()); + } +} diff --git a/ci/tests/ReadmeBanner/Glyphs/TrueTypeFontTest.php b/ci/tests/ReadmeBanner/Glyphs/TrueTypeFontTest.php new file mode 100644 index 000000000..bcdb5af49 --- /dev/null +++ b/ci/tests/ReadmeBanner/Glyphs/TrueTypeFontTest.php @@ -0,0 +1,357 @@ + */ + private array $files = []; + + protected function tearDown(): void + { + foreach ($this->files as $file) { + @unlink($file); + } + } + + public function testReadsTheHeader(): void + { + $path = $this->save(new FontBuilder(2048)); + $font = new TrueTypeFont($path); + + self::assertSame(2048, $font->unitsPerEm()); + self::assertSame($path, $font->path()); + } + + /** + * @return iterable + */ + public static function cmapFormats(): iterable + { + yield 'format 4 with deltas' => [FontBuilder::CMAP_FORMAT_4]; + yield 'format 4 with a glyph array' => [FontBuilder::CMAP_FORMAT_4_GLYPH_ARRAY]; + yield 'format 12' => [FontBuilder::CMAP_FORMAT_12]; + } + + #[DataProvider('cmapFormats')] + public function testMapsCharactersToGlyphs(int $format): void + { + $builder = new FontBuilder(); + $a = $builder->simpleGlyph(600, self::SQUARE); + $b = $builder->emptyGlyph(300); + $builder->map(0x41, $a)->map(0x2026, $b)->map(0x43, 99)->cmapFormat($format); + $font = new TrueTypeFont($this->save($builder)); + + self::assertSame($a, $font->glyphId(0x41)); + self::assertSame($b, $font->glyphId(0x2026)); + self::assertNull($font->glyphId(0x42), 'B is not mapped'); + self::assertNull($font->glyphId(0x43), 'C is mapped to a glyph the font does not have'); + } + + public function testMapsCodePointsAboveTheBasicPlane(): void + { + $builder = new FontBuilder(); + $glyph = $builder->emptyGlyph(1000); + $builder->map(0x1F600, $glyph)->cmapFormat(FontBuilder::CMAP_FORMAT_12); + + self::assertSame($glyph, new TrueTypeFont($this->save($builder))->glyphId(0x1F600)); + } + + public function testPrefersTheFullUnicodeCmap(): void + { + $builder = new FontBuilder(); + $glyph = $builder->emptyGlyph(1000); + $format12 = OpenType::uint16(12, 0) . OpenType::uint32(28, 0, 1, 0x41, 0x41, $glyph); + $format4 = OpenType::uint16(4, 32, 0, 4, 0, 0, 0, 0x41, 0xFFFF, 0, 0x41, 0xFFFF, -0x41, 1, 0, 0); + $cmap = OpenType::uint16(0, 2, 3, 1) . OpenType::uint32(20) . OpenType::uint16(3, 10) . OpenType::uint32(20 + 32) . $format4 . $format12; + $builder->table('cmap', $cmap); + + self::assertSame($glyph, new TrueTypeFont($this->save($builder))->glyphId(0x41), 'format 4 maps A to glyph 0, format 12 to the glyph'); + } + + public function testAdvanceWidthOfGlyphsAfterTheLastMetricIsTheLastAdvance(): void + { + $builder = new FontBuilder(); + $first = $builder->emptyGlyph(600); + $second = $builder->emptyGlyph(700); + $builder->numberOfHMetrics(2); + $font = new TrueTypeFont($this->save($builder)); + + self::assertSame(500, $font->advanceWidth(0)); + self::assertSame(600, $font->advanceWidth($first)); + self::assertSame(600, $font->advanceWidth($second)); + } + + /** + * @return iterable>, 1: string}> + */ + public static function outlines(): iterable + { + yield 'lines' => [self::SQUARE, 'M 100 0 L 500 0 L 500 700 L 100 700 Z']; + yield 'quadratic curve' => [[[[0, 0], [100, 200, false], [200, 0]]], 'M 0 0 Q 100 200 200 0 Z']; + yield 'curve with an implied on-curve point' => [ + [[[0, 0], [0, 100, false], [100, 100, false], [100, 0]]], + 'M 0 0 Q 0 100 50 100 Q 100 100 100 0 Z', + ]; + yield 'contour that starts off the curve' => [ + [[[0, 100, false], [100, 100], [100, 0], [0, 0]]], + 'M 100 100 L 100 0 L 0 0 Q 0 100 100 100 Z', + ]; + yield 'contour without on-curve points' => [ + [[[0, 0, false], [100, 0, false], [100, 100, false], [0, 100, false]]], + 'M 0 50 Q 0 0 50 0 Q 100 0 100 50 Q 100 100 50 100 Q 0 100 0 50 Z', + ]; + yield 'two contours, large and repeated coordinates' => [ + [[[0, 0], [1000, 0], [1000, -900]], [[1000, -900], [1000, -900], [-300, -900]]], + 'M 0 0 L 1000 0 L 1000 -900 Z M 1000 -900 L 1000 -900 L -300 -900 Z', + ]; + } + + /** + * @param list> $contours + */ + #[DataProvider('outlines')] + public function testOutlineStartsEachContourAtItsLastOnCurvePoint(array $contours, string $expected): void + { + $builder = new FontBuilder(); + $glyph = $builder->simpleGlyph(600, $contours); + + self::assertSame($expected, new TrueTypeFont($this->save($builder))->outline($glyph)); + } + + public function testOutlineWithLongLoca(): void + { + $builder = new FontBuilder(); + $builder->simpleGlyph(600, [[[0, 0], [10, 10], [20, 0]]]); + + $glyph = $builder->simpleGlyph(600, self::SQUARE); + $builder->longLoca(); + + self::assertSame('M 100 0 L 500 0 L 500 700 L 100 700 Z', new TrueTypeFont($this->save($builder))->outline($glyph)); + } + + public function testEmptyGlyphHasNoOutline(): void + { + $builder = new FontBuilder(); + $glyph = $builder->emptyGlyph(250); + + self::assertSame('', new TrueTypeFont($this->save($builder))->outline($glyph)); + } + + public function testLeftSideBearingMovesTheGlyph(): void + { + $builder = new FontBuilder(); + $glyph = $builder->simpleGlyph(600, self::SQUARE, 150); + $builder->numberOfHMetrics(1); + + self::assertSame('M 150 0 L 550 0 L 550 700 L 150 700 Z', new TrueTypeFont($this->save($builder))->outline($glyph)); + } + + /** + * @return iterable}>, 1: string}> + */ + public static function components(): iterable + { + yield 'moved' => [[['dx' => 10, 'dy' => -5]], 'M 110 -5 L 510 -5 L 510 695 L 110 695 Z']; + yield 'moved far and scaled' => [[['dx' => 1000, 'dy' => -10, 'scale' => 0.5]], 'M 1050 -10 L 1250 -10 L 1250 340 L 1050 340 Z']; + yield 'x and y scale' => [[['dx' => 0, 'dy' => 0, 'scale' => [1.5, 0.5]]], 'M 150 0 L 750 0 L 750 350 L 150 350 Z']; + yield '2x2 matrix' => [[['dx' => 0, 'dy' => 0, 'scale' => [1.0, 0.0, 0.5, 1.0]]], 'M 100 0 L 500 0 L 850 700 L 450 700 Z']; + yield 'two components' => [ + [['dx' => 0, 'dy' => 0], ['dx' => 600, 'dy' => 0]], + 'M 100 0 L 500 0 L 500 700 L 100 700 Z M 700 0 L 1100 0 L 1100 700 L 700 700 Z', + ]; + } + + /** + * @param list}> $components + */ + #[DataProvider('components')] + public function testCompositeGlyphDrawsItsComponents(array $components, string $expected): void + { + $builder = new FontBuilder(); + // The left side bearing of a component does not move it + $square = $builder->simpleGlyph(600, self::SQUARE, 300); + $glyph = $builder->compositeGlyph(600, array_map(fn (array $component): array => ['glyph' => $square, ...$component], $components)); + + self::assertSame($expected, new TrueTypeFont($this->save($builder))->outline($glyph)); + } + + public function testNestedCompositeGlyphCombinesTheTransformations(): void + { + $builder = new FontBuilder(); + $square = $builder->simpleGlyph(600, self::SQUARE); + $inner = $builder->compositeGlyph(600, [['glyph' => $square, 'dx' => 10, 'dy' => 0, 'scale' => 0.5]]); + $outer = $builder->compositeGlyph(600, [['glyph' => $inner, 'dx' => 100, 'dy' => 0, 'scale' => 1.5]]); + + self::assertSame('M 190 0 L 490 0 L 490 525 L 190 525 Z', new TrueTypeFont($this->save($builder))->outline($outer)); + } + + public function testKerningIsZeroWithoutGpos(): void + { + $builder = new FontBuilder(); + $a = $builder->emptyGlyph(600); + + self::assertSame(0, new TrueTypeFont($this->save($builder))->kerning($a, $a, true)); + } + + public function testKerningComesFromGpos(): void + { + $builder = new FontBuilder(); + $a = $builder->emptyGlyph(600); + $v = $builder->emptyGlyph(600); + $builder->table('GPOS', $this->gpos($a, $v)); + $font = new TrueTypeFont($this->save($builder)); + + self::assertSame(-80, $font->kerning($a, $v, true)); + self::assertSame(0, $font->kerning($v, $a, true)); + } + + public function testKerningUsesTheGlyphClassesOfGdef(): void + { + $builder = new FontBuilder(); + $a = $builder->emptyGlyph(600); + $v = $builder->emptyGlyph(600); + $builder->table('GPOS', $this->gpos($a, $v, 0x0008)); + $builder->table('GDEF', OpenType::gdef(OpenType::classDefFormat1($v, 3))); + + self::assertSame(0, new TrueTypeFont($this->save($builder))->kerning($a, $v, true), 'the lookup ignores marks and GDEF makes V a mark'); + } + + public function testMissingFileIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The font file /does/not/exist.ttf does not exist.'); + new TrueTypeFont('/does/not/exist.ttf'); + } + + /** + * @return iterable + */ + public static function otherFormats(): iterable + { + yield 'CFF outlines' => ['OTTO', 'has PostScript (CFF) outlines. Use the .ttf file of the font.']; + yield 'WOFF' => ['wOFF', 'is a compressed web font. Use the .ttf file of the font.']; + yield 'WOFF2' => ['wOF2', 'is a compressed web font. Use the .ttf file of the font.']; + yield 'something else' => ['%PDF', 'is not a TrueType font (.ttf).']; + } + + #[DataProvider('otherFormats')] + public function testOtherFileFormatsAreRejected(string $version, string $message): void + { + $path = $this->save(new FontBuilder()->version($version)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' ' . $message); + new TrueTypeFont($path); + } + + public function testMacTrueTypeVersionIsAccepted(): void + { + self::assertSame(1000, new TrueTypeFont($this->save(new FontBuilder()->version('true')))->unitsPerEm()); + } + + public function testMissingTableIsRejected(): void + { + $path = $this->save(new FontBuilder()->table('hhea', null)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' has no "hhea" table.'); + new TrueTypeFont($path); + } + + /** + * @return iterable + */ + public static function unsupportedCmaps(): iterable + { + yield 'Macintosh only' => [1, 0, OpenType::uint16(0, 262, 0) . str_repeat("\0", 256), 'has no Unicode character map.']; + yield 'format 6' => [3, 1, OpenType::uint16(6, 10, 0, 0, 0), 'maps Unicode with a cmap subtable of format 6; only the formats 4 and 12 are supported.']; + } + + #[DataProvider('unsupportedCmaps')] + public function testUnsupportedCmapIsRejected(int $platform, int $encoding, string $subtable, string $message): void + { + $path = $this->save(new FontBuilder()->rawCmap($platform, $encoding, $subtable)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' ' . $message); + new TrueTypeFont($path); + } + + public function testCubicOutlinesAreRejected(): void + { + $builder = new FontBuilder(); + $glyph = $builder->simpleGlyph(600, [[[0, 0], [50, 100, 'cubic'], [100, 0]]]); + $path = $this->save($builder); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' has cubic TrueType outlines, which are not supported.'); + new TrueTypeFont($path)->outline($glyph); + } + + public function testComponentPositionedByPointsIsRejected(): void + { + $builder = new FontBuilder(); + $square = $builder->simpleGlyph(600, self::SQUARE); + $glyph = $builder->compositeGlyph(600, [['glyph' => $square, 'dx' => 0, 'dy' => 0, 'matchPoints' => true]]); + $path = $this->save($builder); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' positions a component of a glyph by matching points, which is not supported.'); + new TrueTypeFont($path)->outline($glyph); + } + + public function testGlyphOutsideTheFontIsRejected(): void + { + $builder = new FontBuilder(); + $glyph = $builder->compositeGlyph(600, [['glyph' => 42, 'dx' => 0, 'dy' => 0]]); + $path = $this->save($builder); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' refers to glyph 42, but has only 2 glyphs.'); + new TrueTypeFont($path)->outline($glyph); + } + + public function testEndlessComponentNestingIsRejected(): void + { + $builder = new FontBuilder(); + $glyph = $builder->compositeGlyph(600, [['glyph' => 1, 'dx' => 0, 'dy' => 0]]); + $path = $this->save($builder); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($path . ' has components nested too deep in glyph ' . $glyph . '.'); + new TrueTypeFont($path)->outline($glyph); + } + + /** + * The feature "kern" of the Latin script moves $second 80 units closer to $first. + */ + private function gpos(int $first, int $second, int $lookupFlag = 0): string + { + return OpenType::gpos( + ['latn' => OpenType::script(OpenType::langSys([0]))], + [['kern', [0]]], + [OpenType::lookup(2, [OpenType::pairPosFormat1([$first => [$second => [-80, 0]]])], $lookupFlag)], + ); + } + + private function save(FontBuilder $builder): string + { + return $this->files[] = $builder->save(); + } +} diff --git a/ci/tests/ReadmeBanner/ImageMatrixTest.php b/ci/tests/ReadmeBanner/ImageMatrixTest.php new file mode 100644 index 000000000..1d671d65c --- /dev/null +++ b/ci/tests/ReadmeBanner/ImageMatrixTest.php @@ -0,0 +1,155 @@ +image('php-nginx-dev', '8.5'), + $this->image('php-nginx', '8.5'), + $this->image('php', '8.5', 'php:8.5-fpm-bookworm'), + $this->image('php-apache', '8.5'), + $this->image('base', 'ubuntu-22.04'), + $this->image('toolbox', 'latest'), + ], []); + + self::assertSame(['php', 'php-apache', 'php-nginx'], $matrix->flavors()); + } + + public function testVersionsAreSortedAsVersions(): void + { + $matrix = new ImageMatrix([ + $this->image('php', '8.10', 'php:8.10-fpm-bookworm'), + $this->image('php', '8.4', 'php:8.4-fpm-bookworm'), + $this->image('php-nginx', '8.9-alpine'), + $this->image('php', '7.4', 'php:7.4-fpm-bullseye'), + ], []); + + self::assertSame(['7.4', '8.4', '8.9', '8.10'], $matrix->versions()); + } + + public function testNewestVersionOfAnImageWithASuffix(): void + { + $matrix = new ImageMatrix([ + $this->image('php', '8.5', 'php:8.5-fpm-bookworm'), + $this->image('php-nginx-dev', '8.10-alpine'), + $this->image('php-nginx-dev', '8.9-alpine'), + $this->image('php-nginx-dev', '8.11'), + ], []); + + self::assertSame('8.10', $matrix->newestVersion('php-nginx-dev', 'alpine')); + self::assertSame('8.11', $matrix->newestVersion('php-nginx-dev', ImageMatrix::NO_SUFFIX)); + self::assertNull($matrix->newestVersion('php-nginx-dev', 'trixie')); + self::assertNull($matrix->newestVersion('php-apache-dev', 'alpine')); + } + + public function testBaseSuffixesAreSortedWithoutSuffixFirst(): void + { + $matrix = new ImageMatrix([ + $this->image('php', '8.5-alpine', 'php:8.5-fpm-alpine'), + $this->image('php', '8.5', 'php:8.5-fpm-bookworm'), + $this->image('php-nginx', '8.5-1'), + $this->image('php-nginx', '8.5-alpine'), + ], []); + + self::assertSame([ImageMatrix::NO_SUFFIX, '1', 'alpine'], $matrix->baseSuffixes()); + } + + public function testReleasesOfThePhpImagesOldestVersionFirst(): void + { + $matrix = new ImageMatrix([ + $this->image('php', '8.5', 'php:8.5-fpm-trixie'), + $this->image('php', '8.4', 'php:8.4-fpm-bookworm'), + $this->image('php', '8.4-alpine', 'php:8.4-fpm-alpine3.22'), + $this->image('php-nginx', '8.3', 'webdevops/php:8.3'), + ], []); + + self::assertSame(['8.4' => 'bookworm', '8.5' => 'trixie'], $matrix->releases(ImageMatrix::NO_SUFFIX)); + self::assertSame(['8.4' => 'alpine3.22'], $matrix->releases('alpine')); + self::assertSame([], $matrix->releases('trixie')); + } + + /** + * @return iterable + */ + public static function baseImages(): iterable + { + yield 'variant and release' => ['php:8.5-fpm-bookworm', 'bookworm']; + yield 'release only' => ['php:8.5-trixie', 'trixie']; + yield 'registry with a port' => ['registry.example.com:5000/library/php:8.5-zts-bookworm', 'bookworm']; + yield 'digest' => ['php:8.5-cli-alpine3.22@sha256:0123456789abcdef', 'alpine3.22']; + } + + #[DataProvider('baseImages')] + public function testReleaseIsTheLastPartOfTheTag(string $baseImage, string $release): void + { + $matrix = new ImageMatrix([$this->image('php', '8.5', $baseImage)], []); + + self::assertSame(['8.5' => $release], $matrix->releases(ImageMatrix::NO_SUFFIX)); + } + + /** + * @return iterable + */ + public static function baseImagesWithoutRelease(): iterable + { + yield 'variant only' => ['php:8.5-fpm']; + yield 'version only' => ['php:8.5']; + yield 'no tag' => ['php']; + yield 'unknown' => [null]; + } + + #[DataProvider('baseImagesWithoutRelease')] + public function testBaseImageWithoutReleaseIsAnError(?string $baseImage): void + { + $matrix = new ImageMatrix([$this->image('php', '8.5', $baseImage)], []); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The README banner found no release in "' . $baseImage . '", the image webdevops/php:8.5 is built from. Name it in the tag, e.g. php:8.5-fpm-bookworm.', + ); + $matrix->releases(ImageMatrix::NO_SUFFIX); + } + + public function testPlatformsAreSorted(): void + { + $matrix = new ImageMatrix([$this->image('php', '8.5')], ['linux/arm64', 'linux/amd64']); + + self::assertSame(['linux/amd64', 'linux/arm64'], $matrix->platforms()); + } + + public function testTagWithoutPhpVersionIsAnError(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The README banner cannot take apart the tag webdevops/php-nginx:latest. It expects the PHP version and an optional suffix, e.g. 8.5 or 8.5-alpine.', + ); + new ImageMatrix([$this->image('php', '8.5'), $this->image('php-nginx', 'latest')], []); + } + + public function testWebdevopsPhpIsNeeded(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The README banner found no webdevops/php images to show.'); + new ImageMatrix([$this->image('php-nginx', '8.5'), $this->image('base', 'ubuntu-22.04')], []); + } + + /** + * @return array{image: string, tag: string, from: ?string} + */ + private function image(string $image, string $tag, ?string $from = null): array + { + return ['image' => $image, 'tag' => $tag, 'from' => $from]; + } +} diff --git a/ci/tests/ReadmeBanner/SvgTest.php b/ci/tests/ReadmeBanner/SvgTest.php new file mode 100644 index 000000000..8e0fe0e4c --- /dev/null +++ b/ci/tests/ReadmeBanner/SvgTest.php @@ -0,0 +1,39 @@ + + */ + public static function numbers(): iterable + { + yield 'integer' => [12.0, '12']; + yield 'one decimal' => [12.5, '12.5']; + yield 'rounded' => [3.14159, '3.1']; + yield 'rounded up to an integer' => [9.96, '10']; + yield 'negative' => [-7.25001, '-7.3']; + yield 'negative zero' => [-0.04, '0']; + yield 'zero' => [0.0, '0']; + } + + #[DataProvider('numbers')] + public function testNumberHasAtMostOneDecimal(float $value, string $expected): void + { + self::assertSame($expected, Svg::number($value)); + } + + public function testEscapeForXml(): void + { + self::assertSame('<a href="x">Tom & Jerry's</a>', Svg::escape('Tom & Jerry\'s')); + } +} diff --git a/ci/tests/TemporaryRepository.php b/ci/tests/TemporaryRepository.php new file mode 100644 index 000000000..c80f3b9f6 --- /dev/null +++ b/ci/tests/TemporaryRepository.php @@ -0,0 +1,96 @@ +path = sys_get_temp_dir() . '/webdevops-ci-test-' . bin2hex(random_bytes(6)); + mkdir($this->path); + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(self::FIXTURE, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($files as $file) { + if (!$file instanceof SplFileInfo) { + continue; + } + + $target = $this->path . substr($file->getPathname(), strlen(self::FIXTURE)); + if ($file->isLink()) { + // Like docker/php/, which links to docker/php-official/ + symlink((string)readlink($file->getPathname()), $target); + } elseif ($file->isDir()) { + mkdir($target); + } else { + copy($file->getPathname(), $target); + } + } + + // The real glyph table, which the README banner draws with + $this->write('ci/resources/readme-banner/glyphs.json', (string)file_get_contents(self::GLYPHS)); + } + + public function read(string $file): string + { + return (string)file_get_contents($this->path . '/' . $file); + } + + public function write(string $file, string $content): void + { + $path = $this->path . '/' . $file; + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $content); + } + + public function remove(): void + { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + if ($file instanceof SplFileInfo) { + $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + } + + rmdir($this->path); + } +}