From 43163127973b244f83ef91b4ea44145b0f9356f3 Mon Sep 17 00:00:00 2001 From: Matthias Vogel Date: Mon, 9 Nov 2026 13:19:42 +0000 Subject: [PATCH 1/3] [TASK] Check the ci/ PHP code with GrumPHP and add PHPUnit tests ci/ now uses pluswerk/grumphp-config: composer validate and normalize, PSR-12, PHPStan level 8, Rector, composer audit and PHPUnit, run by `vendor/bin/grumphp run`. The new CI job "Check ci/" runs it; no image build waits for it. The code is changed only as far as the checks require, and the generated files stay byte-identical: .github/workflows/build.yaml (apart from the new job), docs/assets/readme-banner-*.svg and glyphs.json. - Types for PHPStan level 8, Rector's PHP 8.4 and code quality rules - FileReader, GithubJobBuilder and the commands take the repository root, so the tests can run them against tests/Fixtures/repository - FileReader::collectDockerfiles() returns a sorted list of paths - bluem/tree 4.0: Node keeps the case of property keys, so the workaround for imageParent/imageparent is gone, and so is the PHP 8.4 deprecation - --blacklist entries match at the start of the image name too - symfony/yaml 6.4.50 for a security advisory 184 PHPUnit tests cover every class in ci/src. TrueTypeFont and GposKerning are tested with small fonts the tests write themselves. --- .github/workflows/build.yaml | 13 + AGENTS.md | 4 + README.md | 9 + ci/composer.json | 62 +- ci/composer.lock | 7662 ++++++++++++++++- ci/fractor.php | 18 + ci/grumphp.yml | 24 + ci/phpstan-baseline.neon | 2 + ci/phpstan.neon | 7 + ci/phpunit.xml.dist | 22 + ci/rector.php | 39 + ci/src/Commands/BuildGlyphsCommand.php | 5 +- ci/src/Commands/GithubCommand.php | 91 +- ci/src/Commands/ReadmeBannerCommand.php | 17 +- ci/src/FileReader.php | 82 +- ci/src/GithubJobBuilder.php | 108 +- ci/src/ReadmeBanner/BannerGenerator.php | 15 +- ci/src/ReadmeBanner/BannerRenderer.php | 244 +- ci/src/ReadmeBanner/GlyphFont.php | 25 +- ci/src/ReadmeBanner/Glyphs/BinaryData.php | 4 +- .../ReadmeBanner/Glyphs/GlyphTableBuilder.php | 11 +- ci/src/ReadmeBanner/Glyphs/GposKerning.php | 62 +- ci/src/ReadmeBanner/Glyphs/OutlinePen.php | 7 +- ci/src/ReadmeBanner/Glyphs/TrueTypeFont.php | 113 +- ci/src/ReadmeBanner/ImageMatrix.php | 30 +- ci/src/ReadmeBanner/Svg.php | 3 + ci/tests/Commands/BuildGlyphsCommandTest.php | 86 + ci/tests/Commands/GithubCommandTest.php | 194 + ci/tests/Commands/ReadmeBannerCommandTest.php | 44 + ci/tests/FileReaderTest.php | 162 + ci/tests/Fixtures/repository/conf/console.yml | 21 + .../repository/conf/readme-banner.yml | 38 + .../docker/base-app/ubuntu-22.04/Dockerfile | 6 + .../repository/docker/base/alpine/Dockerfile | 8 + .../docker/base/ubuntu-22.04/Dockerfile | 8 + .../base/ubuntu-22.04/Dockerfile.jinja2 | 1 + .../php-nginx-dev/8.5-alpine/Dockerfile | 6 + .../docker/php-nginx/8.5-alpine/Dockerfile | 6 + .../docker/php-official/8.4/Dockerfile | 8 + .../docker/php-official/8.5-alpine/Dockerfile | 8 + .../docker/php-official/8.5/Dockerfile | 8 + .../docker/toolbox/latest/Dockerfile | 8 + .../tests/serverspec/spec/docker/base_spec.rb | 0 .../structure-test/base/alpine/test.yaml | 1 + .../tests/structure-test/base/test.yaml | 1 + ci/tests/GithubJobBuilderTest.php | 304 + ci/tests/ReadmeBanner/BannerGeneratorTest.php | 76 + ci/tests/ReadmeBanner/BannerRendererTest.php | 170 + ci/tests/ReadmeBanner/GlyphFontTest.php | 69 + .../ReadmeBanner/Glyphs/BinaryDataTest.php | 81 + ci/tests/ReadmeBanner/Glyphs/FontBuilder.php | 330 + .../Glyphs/GlyphTableBuilderTest.php | 118 + .../ReadmeBanner/Glyphs/GposKerningTest.php | 269 + ci/tests/ReadmeBanner/Glyphs/OpenType.php | 325 + .../ReadmeBanner/Glyphs/OutlinePenTest.php | 61 + .../ReadmeBanner/Glyphs/TrueTypeFontTest.php | 357 + ci/tests/ReadmeBanner/ImageMatrixTest.php | 155 + ci/tests/ReadmeBanner/SvgTest.php | 39 + ci/tests/TemporaryRepository.php | 91 + 59 files changed, 11501 insertions(+), 237 deletions(-) create mode 100644 ci/fractor.php create mode 100644 ci/grumphp.yml create mode 100644 ci/phpstan-baseline.neon create mode 100644 ci/phpstan.neon create mode 100644 ci/phpunit.xml.dist create mode 100644 ci/rector.php create mode 100644 ci/tests/Commands/BuildGlyphsCommandTest.php create mode 100644 ci/tests/Commands/GithubCommandTest.php create mode 100644 ci/tests/Commands/ReadmeBannerCommandTest.php create mode 100644 ci/tests/FileReaderTest.php create mode 100644 ci/tests/Fixtures/repository/conf/console.yml create mode 100644 ci/tests/Fixtures/repository/conf/readme-banner.yml create mode 100644 ci/tests/Fixtures/repository/docker/base-app/ubuntu-22.04/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/base/alpine/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/base/ubuntu-22.04/Dockerfile.jinja2 create mode 100644 ci/tests/Fixtures/repository/docker/php-nginx-dev/8.5-alpine/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/php-nginx/8.5-alpine/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/php-official/8.4/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/php-official/8.5-alpine/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/php-official/8.5/Dockerfile create mode 100644 ci/tests/Fixtures/repository/docker/toolbox/latest/Dockerfile create mode 100644 ci/tests/Fixtures/repository/tests/serverspec/spec/docker/base_spec.rb create mode 100644 ci/tests/Fixtures/repository/tests/structure-test/base/alpine/test.yaml create mode 100644 ci/tests/Fixtures/repository/tests/structure-test/base/test.yaml create mode 100644 ci/tests/GithubJobBuilderTest.php create mode 100644 ci/tests/ReadmeBanner/BannerGeneratorTest.php create mode 100644 ci/tests/ReadmeBanner/BannerRendererTest.php create mode 100644 ci/tests/ReadmeBanner/GlyphFontTest.php create mode 100644 ci/tests/ReadmeBanner/Glyphs/BinaryDataTest.php create mode 100644 ci/tests/ReadmeBanner/Glyphs/FontBuilder.php create mode 100644 ci/tests/ReadmeBanner/Glyphs/GlyphTableBuilderTest.php create mode 100644 ci/tests/ReadmeBanner/Glyphs/GposKerningTest.php create mode 100644 ci/tests/ReadmeBanner/Glyphs/OpenType.php create mode 100644 ci/tests/ReadmeBanner/Glyphs/OutlinePenTest.php create mode 100644 ci/tests/ReadmeBanner/Glyphs/TrueTypeFontTest.php create mode 100644 ci/tests/ReadmeBanner/ImageMatrixTest.php create mode 100644 ci/tests/ReadmeBanner/SvgTest.php create mode 100644 ci/tests/TemporaryRepository.php diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 40c6149c1..757a131f3 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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..c252f733d 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/`. PHPStan and Rector only see files that Git knows, so `git add` new files first. `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..8fa65ec16 100644 --- a/ci/src/Commands/BuildGlyphsCommand.php +++ b/ci/src/Commands/BuildGlyphsCommand.php @@ -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..434487f68 100644 --- a/ci/src/Commands/GithubCommand.php +++ b/ci/src/Commands/GithubCommand.php @@ -12,35 +12,61 @@ use Webdevops\Build\FileReader; use Webdevops\Build\GithubJobBuilder; +use function file; +use function file_exists; +use function file_put_contents; use function ksort; +use function str_pad; +use function str_contains; +/** + * Writes .github/workflows/build.yaml: a build and a publish job for every Dockerfile under docker/. + */ class GithubCommand extends Command { - protected $output; - protected $fileReader; - protected $jobBuilder; - protected $jobs = []; - protected $deepestLevel = 0; - protected $blacklist = []; - protected $_settings = []; - protected static $defaultName = 'github:generate-ci'; - public function __construct() + private OutputInterface $output; + + private readonly FileReader $fileReader; + + private readonly GithubJobBuilder $jobBuilder; + + /** + * @var array> + */ + private array $jobs = []; + + /** + * @var array + */ + private array $blacklist = []; + + /** + * @var array conf/console.yml + */ + private readonly array $settings; + + /** + * @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'); + $this->settings = Yaml::parseFile($this->rootPath . '/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); + if ($this->blacklist === [] && file_exists($this->rootPath . '/ci/BLACKLIST')) { + $this->blacklist = file($this->rootPath . '/ci/BLACKLIST', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; } + + $this->jobs = []; $this->output = $output; $tree = $this->buildTree(); foreach ($tree->getRootNodes() as $rootNode) { @@ -51,8 +77,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,26 +111,16 @@ 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), @@ -111,11 +128,12 @@ private function traverse(Node $node): void 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->settings['docker']['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); @@ -124,21 +142,16 @@ private function traverse(Node $node): void 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..32936d0f4 100644 --- a/ci/src/Commands/ReadmeBannerCommand.php +++ b/ci/src/Commands/ReadmeBannerCommand.php @@ -10,7 +10,6 @@ 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. @@ -20,6 +19,14 @@ 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 { $this->setDescription('Renders the README banner (docs/assets/readme-banner-*.svg) from the Dockerfiles'); @@ -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..b9c59eb20 100644 --- a/ci/src/FileReader.php +++ b/ci/src/FileReader.php @@ -2,37 +2,63 @@ 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 path of every Dockerfile under docker/, sorted. + * + * @return list + */ + public function collectDockerfiles(): array { - $dockerDir = new RecursiveDirectoryIterator(__DIR__ . '/../../docker/'); - $iterator = new RecursiveIteratorIterator($dockerDir); - return new RegexIterator($iterator, '/^.*Dockerfile$/i', RecursiveRegexIterator::GET_MATCH); + $dockerfiles = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->rootPath . '/docker/')); + foreach ($files as $file) { + if ($file instanceof SplFileInfo && preg_match('/^.*Dockerfile$/i', $file->getPathname())) { + $dockerfiles[] = $file->getPathname(); + } + } + + sort($dockerfiles); + return $dockerfiles; } - public function getInfo(string $dockerfilePath) + /** + * The build tree node of a generated Dockerfile: image, tag, parent image and test settings. + * + * @return array + */ + public function getInfo(string $dockerfilePath): array { $content = file_get_contents($dockerfilePath); // Extract info from file header - preg_match('/# Dockerfile for webdevops\/(.*):(.*)/', $content, $headerMatches); + if ($content === false || !preg_match('/# Dockerfile for webdevops\/(.*):(.*)/', $content, $headerMatches)) { + throw new RuntimeException(sprintf('%s has no "# Dockerfile for webdevops/:" header.', $dockerfilePath)); + } + $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, @@ -50,22 +76,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 +105,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 +123,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->settings['docker']['autoLatestTag']) { $node['aliases'][] = str_replace(':' . $tagName, ':latest', $id); } + return $node; } @@ -127,7 +158,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 +166,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->settings['docker']['autoLatestTag'])) { + $tag = $this->settings['docker']['autoLatestTag']; } + return 'webdevops/' . $image . ':' . $tag; } - } diff --git a/ci/src/GithubJobBuilder.php b/ci/src/GithubJobBuilder.php index b1473611e..7dd6e5556 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, hasChildren and the image keys restored * @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. + * + * @param array $node + */ + private function context(array $node): string + { + return dirname(str_replace($this->rootPath . '/', '', $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 [ 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::assertIsString($default); + self::assertSame(realpath(__DIR__ . '/../../resources/readme-banner/glyphs.json'), realpath($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..221f91f8c --- /dev/null +++ b/ci/tests/Commands/GithubCommandTest.php @@ -0,0 +1,194 @@ +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']; + + self::assertStringContainsString('-t "webdevops/base:latest"', $jobs['base_ubuntu-22-04_publish']['steps'][3]['run']); + self::assertStringContainsString('-t "webdevops/php-official:8.4"', $jobs['php_8-4_publish']['steps'][3]['run']); + } + + 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']]; + } + + /** + * @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()); + } + + public function testBlacklistFile(): void + { + $this->repository->write('ci/BLACKLIST', "php-nginx-dev\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..39315ab53 --- /dev/null +++ b/ci/tests/FileReaderTest.php @@ -0,0 +1,162 @@ +collectDockerfiles(); + + self::assertSame([ + 'docker/base-app/ubuntu-22.04/Dockerfile', + 'docker/base/alpine/Dockerfile', + 'docker/base/ubuntu-22.04/Dockerfile', + 'docker/php-nginx-dev/8.5-alpine/Dockerfile', + 'docker/php-nginx/8.5-alpine/Dockerfile', + 'docker/php-official/8.4/Dockerfile', + 'docker/php-official/8.5-alpine/Dockerfile', + 'docker/php-official/8.5/Dockerfile', + 'docker/toolbox/latest/Dockerfile', + ], array_map(fn (string $file): string => str_replace(self::ROOT . '/', '', $file), $files)); + } + + 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(self::ROOT . '/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 testDockerfileWithoutHeaderIsAnError(): void + { + $file = (string)tempnam(sys_get_temp_dir(), 'Dockerfile'); + file_put_contents($file, "FROM alpine:3.22\n"); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($file . ' has no "# Dockerfile for webdevops/:" header.'); + new FileReader(self::ROOT)->getInfo($file); + } finally { + unlink($file); + } + } + + /** + * @return array + */ + private function info(string $directory): array + { + return new FileReader(self::ROOT)->getInfo(self::ROOT . '/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/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..c1701102e --- /dev/null +++ b/ci/tests/GithubJobBuilderTest.php @@ -0,0 +1,304 @@ + + */ + 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'))); + } + + 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' => self::ROOT . '/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..cc3854f78 --- /dev/null +++ b/ci/tests/TemporaryRepository.php @@ -0,0 +1,91 @@ +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->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() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + } + + rmdir($this->path); + } +} From 3a2aa59e8e5d00b5b3c477b4f0a0138834211588 Mon Sep 17 00:00:00 2001 From: Matthias Vogel Date: Mon, 9 Nov 2026 13:40:25 +0000 Subject: [PATCH 2/3] [TASK] Fix the review findings of the ci/ checks - Empty and blank --blacklist entries, and -b without a value, match no image any more; str_contains() matched every image with them, and a missing value ended in a TypeError. Blank lines in ci/BLACKLIST are skipped the same way. - The commands get their names from #[AsCommand] instead of the static $defaultName that symfony/console deprecated in 6.1. - FileReader returns the Dockerfiles relative to the repository root, so the build context no longer depends on both classes getting the same root path string. GithubCommand takes autoLatestTag from FileReader instead of parsing conf/console.yml a second time. - "Validate Automation" installs the generators with --no-dev. - The test repository has docker/php/8.4 as a symbolic link, like docker/php/, and the tests check it is not built twice. - Tests no longer pick a step by its index or compare two realpath() results that can both be false; AGENTS.md says that GrumPHP, not only PHPStan and Rector, checks files Git knows only. --- .github/workflows/build.yaml | 2 +- AGENTS.md | 2 +- ci/src/Commands/BuildGlyphsCommand.php | 4 +-- ci/src/Commands/GithubCommand.php | 36 +++++++++++-------- ci/src/Commands/ReadmeBannerCommand.php | 4 +-- ci/src/FileReader.php | 31 +++++++++++----- ci/src/GithubJobBuilder.php | 9 ++--- ci/tests/Commands/BuildGlyphsCommandTest.php | 6 ++-- ci/tests/Commands/GithubCommandTest.php | 30 ++++++++++++++-- ci/tests/FileReaderTest.php | 38 +++++++++++--------- ci/tests/Fixtures/repository/docker/php/8.4 | 1 + ci/tests/GithubJobBuilderTest.php | 7 +++- ci/tests/TemporaryRepository.php | 9 +++-- 13 files changed, 121 insertions(+), 58 deletions(-) create mode 120000 ci/tests/Fixtures/repository/docker/php/8.4 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 757a131f3..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 - diff --git a/AGENTS.md b/AGENTS.md index c252f733d..6058067d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Only these commands are known to work: - `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/`. PHPStan and Rector only see files that Git knows, so `git add` new files first. `vendor/bin/phpunit` instead of `vendor/bin/grumphp run` runs only the tests. Changes to `ci/src` come with tests. +- `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/ci/src/Commands/BuildGlyphsCommand.php b/ci/src/Commands/BuildGlyphsCommand.php index 8fa65ec16..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 diff --git a/ci/src/Commands/GithubCommand.php b/ci/src/Commands/GithubCommand.php index 434487f68..bb39d0082 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,20 +13,23 @@ use Webdevops\Build\FileReader; use Webdevops\Build\GithubJobBuilder; +use function array_filter; +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_pad; 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 static $defaultName = 'github:generate-ci'; - private OutputInterface $output; private readonly FileReader $fileReader; @@ -38,15 +42,10 @@ class GithubCommand extends Command private array $jobs = []; /** - * @var array + * @var list */ private array $blacklist = []; - /** - * @var array conf/console.yml - */ - private readonly array $settings; - /** * @param string $rootPath the repository root */ @@ -56,14 +55,13 @@ public function __construct(private readonly string $rootPath = __DIR__ . '/../. $this->jobBuilder = new GithubJobBuilder($this->rootPath); parent::__construct(); $this->addOption('blacklist', 'b', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY); - $this->settings = Yaml::parseFile($this->rootPath . '/conf/console.yml'); } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->blacklist = $input->getOption('blacklist'); + $this->blacklist = $this->blacklistEntries($input->getOption('blacklist')); if ($this->blacklist === [] && file_exists($this->rootPath . '/ci/BLACKLIST')) { - $this->blacklist = file($this->rootPath . '/ci/BLACKLIST', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $this->blacklist = $this->blacklistEntries(file($this->rootPath . '/ci/BLACKLIST', FILE_IGNORE_NEW_LINES) ?: []); } $this->jobs = []; @@ -126,9 +124,8 @@ private function traverse(Node $node): void ...$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']); } @@ -140,6 +137,17 @@ private function traverse(Node $node): void } } + /** + * The entries of --blacklist or ci/BLACKLIST without empty ones, which would match every image. + * + * @param array $entries + * @return list + */ + private function blacklistEntries(array $entries): array + { + return array_values(array_filter($entries, fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '')); + } + private function isNameBlacklisted(string $name): bool { return array_any($this->blacklist, fn (string $blacklistItem): bool => str_contains($name, $blacklistItem)); diff --git a/ci/src/Commands/ReadmeBannerCommand.php b/ci/src/Commands/ReadmeBannerCommand.php index 32936d0f4..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; @@ -15,10 +16,9 @@ * 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 */ diff --git a/ci/src/FileReader.php b/ci/src/FileReader.php index b9c59eb20..dc7090816 100644 --- a/ci/src/FileReader.php +++ b/ci/src/FileReader.php @@ -24,17 +24,26 @@ public function __construct(private readonly string $rootPath = __DIR__ . '/../. } /** - * The path of every Dockerfile under docker/, sorted. + * The tag that is published as latest too, e.g. ubuntu-22.04. + */ + public function autoLatestTag(): string + { + return $this->settings['docker']['autoLatestTag']; + } + + /** + * 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/')); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->rootPath . '/docker')); foreach ($files as $file) { if ($file instanceof SplFileInfo && preg_match('/^.*Dockerfile$/i', $file->getPathname())) { - $dockerfiles[] = $file->getPathname(); + $dockerfiles[] = substr($file->getPathname(), strlen($this->rootPath) + 1); } } @@ -45,14 +54,20 @@ public function collectDockerfiles(): array /** * 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 $dockerfilePath): 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 - if ($content === false || !preg_match('/# Dockerfile for webdevops\/(.*):(.*)/', $content, $headerMatches)) { - throw new RuntimeException(sprintf('%s has no "# Dockerfile for webdevops/:" header.', $dockerfilePath)); + if (!preg_match('/# Dockerfile for webdevops\/(.*):(.*)/', $content, $headerMatches)) { + throw new RuntimeException(sprintf('%s has no "# Dockerfile for webdevops/:" header.', $dockerfile)); } $imageName = $headerMatches[1]; @@ -66,7 +81,7 @@ public function getInfo(string $dockerfilePath): array 'image' => $imageName, 'tag' => $tagName, 'aliases' => [], - 'file' => $dockerfilePath, + 'file' => $dockerfile, 'parent' => 0, 'imageParent' => 0, 'imageParentRef' => null, diff --git a/ci/src/GithubJobBuilder.php b/ci/src/GithubJobBuilder.php index 7dd6e5556..e60ee0121 100644 --- a/ci/src/GithubJobBuilder.php +++ b/ci/src/GithubJobBuilder.php @@ -43,7 +43,7 @@ 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, hasChildren and the image keys restored + * @param array $node node from FileReader::getInfo() with level and hasChildren * @return array> */ public function getJobsDescription(array $node): array @@ -225,13 +225,13 @@ public static function toJobId(string $name): string } /** - * The directory of the node's Dockerfile, relative to the repository root. + * 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(str_replace($this->rootPath . '/', '', $node['file'])); + return dirname($node['file']); } /** @@ -477,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/tests/Commands/BuildGlyphsCommandTest.php b/ci/tests/Commands/BuildGlyphsCommandTest.php index 46737ace4..b2cc5ea10 100644 --- a/ci/tests/Commands/BuildGlyphsCommandTest.php +++ b/ci/tests/Commands/BuildGlyphsCommandTest.php @@ -15,10 +15,10 @@ use function array_keys; use function array_map; +use function dirname; use function file_get_contents; use function json_decode; use function mb_ord; -use function realpath; use function sys_get_temp_dir; use function tempnam; use function unlink; @@ -66,8 +66,8 @@ public function testWritesTheGlyphTableOfTheBannerByDefault(): void { $default = new BuildGlyphsCommand()->getDefinition()->getOption('output')->getDefault(); - self::assertIsString($default); - self::assertSame(realpath(__DIR__ . '/../../resources/readme-banner/glyphs.json'), realpath($default)); + self::assertSame(dirname(__DIR__, 3) . '/ci/resources/readme-banner/glyphs.json', $default); + self::assertFileExists($default); } /** diff --git a/ci/tests/Commands/GithubCommandTest.php b/ci/tests/Commands/GithubCommandTest.php index 221f91f8c..f2e7f1345 100644 --- a/ci/tests/Commands/GithubCommandTest.php +++ b/ci/tests/Commands/GithubCommandTest.php @@ -102,9 +102,10 @@ public function testParentImagesArePassedOnBetweenJobs(): void 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"', $jobs['base_ubuntu-22-04_publish']['steps'][3]['run']); - self::assertStringContainsString('-t "webdevops/php-official:8.4"', $jobs['php_8-4_publish']['steps'][3]['run']); + 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 @@ -144,9 +145,32 @@ public function testBlacklistedImagesAreLeftOut(array $blacklist, array $removed 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\nphp:8.4\n"); + $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'])); } diff --git a/ci/tests/FileReaderTest.php b/ci/tests/FileReaderTest.php index 39315ab53..543152dec 100644 --- a/ci/tests/FileReaderTest.php +++ b/ci/tests/FileReaderTest.php @@ -9,13 +9,6 @@ use RuntimeException; use Webdevops\Build\FileReader; -use function array_map; -use function file_put_contents; -use function str_replace; -use function sys_get_temp_dir; -use function tempnam; -use function unlink; - #[CoversClass(FileReader::class)] final class FileReaderTest extends TestCase { @@ -23,8 +16,7 @@ final class FileReaderTest extends TestCase public function testCollectsTheGeneratedDockerfilesSorted(): void { - $files = new FileReader(self::ROOT)->collectDockerfiles(); - + // docker/php/8.4 links to docker/php-official/8.4 and is left out, as in the repository self::assertSame([ 'docker/base-app/ubuntu-22.04/Dockerfile', 'docker/base/alpine/Dockerfile', @@ -35,7 +27,7 @@ public function testCollectsTheGeneratedDockerfilesSorted(): void 'docker/php-official/8.5-alpine/Dockerfile', 'docker/php-official/8.5/Dockerfile', 'docker/toolbox/latest/Dockerfile', - ], array_map(fn (string $file): string => str_replace(self::ROOT . '/', '', $file), $files)); + ], new FileReader(self::ROOT)->collectDockerfiles()); } public function testImageWithAnOutsideBaseImageIsBuiltAfterTheToolbox(): void @@ -46,7 +38,7 @@ public function testImageWithAnOutsideBaseImageIsBuiltAfterTheToolbox(): void self::assertSame('webdevops/base:ubuntu-22.04', $node['name']); self::assertSame('base', $node['image']); self::assertSame('ubuntu-22.04', $node['tag']); - self::assertSame(self::ROOT . '/docker/base/ubuntu-22.04/Dockerfile', $node['file']); + 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'); @@ -138,25 +130,37 @@ public function testCopyFromAnInternalImageIsADependency(): void ); } + public function testAutoLatestTag(): void + { + self::assertSame('ubuntu-22.04', new FileReader(self::ROOT)->autoLatestTag()); + } + public function testDockerfileWithoutHeaderIsAnError(): void { - $file = (string)tempnam(sys_get_temp_dir(), 'Dockerfile'); - file_put_contents($file, "FROM alpine:3.22\n"); + $repository = new TemporaryRepository(); + $repository->write('docker/broken/latest/Dockerfile', "FROM alpine:3.22\n"); try { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage($file . ' has no "# Dockerfile for webdevops/:" header.'); - new FileReader(self::ROOT)->getInfo($file); + $this->expectExceptionMessage('docker/broken/latest/Dockerfile has no "# Dockerfile for webdevops/:" header.'); + new FileReader($repository->path)->getInfo('docker/broken/latest/Dockerfile'); } finally { - unlink($file); + $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(self::ROOT . '/docker/' . $directory . '/Dockerfile'); + return new FileReader(self::ROOT)->getInfo('docker/' . $directory . '/Dockerfile'); } } 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/GithubJobBuilderTest.php b/ci/tests/GithubJobBuilderTest.php index c1701102e..e44b0e0d4 100644 --- a/ci/tests/GithubJobBuilderTest.php +++ b/ci/tests/GithubJobBuilderTest.php @@ -235,6 +235,11 @@ public function testValidationJobChecksTheGeneratedFiles(): void '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 @@ -261,7 +266,7 @@ private function jobs(array $node = []): array 'image' => 'base', 'tag' => 'alpine', 'aliases' => [], - 'file' => self::ROOT . '/docker/base/alpine/Dockerfile', + 'file' => 'docker/base/alpine/Dockerfile', 'parent' => 'webdevops/toolbox:latest', 'imageParent' => 0, 'imageParentRef' => null, diff --git a/ci/tests/TemporaryRepository.php b/ci/tests/TemporaryRepository.php index cc3854f78..c80f3b9f6 100644 --- a/ci/tests/TemporaryRepository.php +++ b/ci/tests/TemporaryRepository.php @@ -17,9 +17,11 @@ use function is_dir; use function mkdir; use function random_bytes; +use function readlink; use function rmdir; use function strlen; use function substr; +use function symlink; use function sys_get_temp_dir; use function unlink; @@ -48,7 +50,10 @@ public function __construct() } $target = $this->path . substr($file->getPathname(), strlen(self::FIXTURE)); - if ($file->isDir()) { + 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); @@ -82,7 +87,7 @@ public function remove(): void ); foreach ($files as $file) { if ($file instanceof SplFileInfo) { - $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } } From b3e2a842a60294ea4b5e8559c91eaf003412d192 Mon Sep 17 00:00:00 2001 From: Matthias Vogel Date: Mon, 9 Nov 2026 14:37:48 +0000 Subject: [PATCH 3/3] [BUGFIX] Trim blacklist entries and check the auto latest tag - Entries of --blacklist and ci/BLACKLIST lose surrounding whitespace; an entry such as "php-nginx " matched no image, so the image was built anyway. - "docker: autoLatestTag" in conf/console.yml has to be a quoted tag. An unquoted 22.10 is a number in YAML and ended in a TypeError; a missing or empty value now stops with a message, too. --- ci/src/Commands/GithubCommand.php | 7 +++++-- ci/src/FileReader.php | 14 +++++++++---- ci/tests/Commands/GithubCommandTest.php | 3 ++- ci/tests/FileReaderTest.php | 28 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/ci/src/Commands/GithubCommand.php b/ci/src/Commands/GithubCommand.php index bb39d0082..00f6dc69d 100644 --- a/ci/src/Commands/GithubCommand.php +++ b/ci/src/Commands/GithubCommand.php @@ -14,6 +14,7 @@ use Webdevops\Build\GithubJobBuilder; use function array_filter; +use function array_map; use function array_values; use function file; use function file_exists; @@ -138,14 +139,16 @@ private function traverse(Node $node): void } /** - * The entries of --blacklist or ci/BLACKLIST without empty ones, which would match every image. + * 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 { - return array_values(array_filter($entries, fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '')); + $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 diff --git a/ci/src/FileReader.php b/ci/src/FileReader.php index dc7090816..26db64504 100644 --- a/ci/src/FileReader.php +++ b/ci/src/FileReader.php @@ -28,7 +28,13 @@ public function __construct(private readonly string $rootPath = __DIR__ . '/../. */ public function autoLatestTag(): string { - return $this->settings['docker']['autoLatestTag']; + $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; } /** @@ -146,7 +152,7 @@ public function getInfo(string $dockerfile): array $node['image'] = str_replace('-official', '', $node['image']); } - if ($tagName === $this->settings['docker']['autoLatestTag']) { + if ($tagName === $this->autoLatestTag()) { $node['aliases'][] = str_replace(':' . $tagName, ':latest', $id); } @@ -185,8 +191,8 @@ private function resolveInternalImageReference(string $reference): string $dockerfileExists = fn (string $tag): bool => file_exists( $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/tests/Commands/GithubCommandTest.php b/ci/tests/Commands/GithubCommandTest.php index f2e7f1345..5ee9a604d 100644 --- a/ci/tests/Commands/GithubCommandTest.php +++ b/ci/tests/Commands/GithubCommandTest.php @@ -129,6 +129,7 @@ public static function blacklists(): iterable 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']]; } /** @@ -170,7 +171,7 @@ public function testEmptyBlacklistEntriesMatchNothing(array $blacklist): void public function testBlacklistFile(): void { - $this->repository->write('ci/BLACKLIST', "php-nginx-dev\n\n \nphp:8.4\n"); + $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'])); } diff --git a/ci/tests/FileReaderTest.php b/ci/tests/FileReaderTest.php index 543152dec..a46ee0af3 100644 --- a/ci/tests/FileReaderTest.php +++ b/ci/tests/FileReaderTest.php @@ -5,10 +5,13 @@ namespace Webdevops\Build\Tests; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use Webdevops\Build\FileReader; +use function str_replace; + #[CoversClass(FileReader::class)] final class FileReaderTest extends TestCase { @@ -135,6 +138,31 @@ 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();