From 7bbd2a3fc2044e5f9c66a6cbb4c207fd4340990a Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 10:24:42 -0500 Subject: [PATCH 01/27] config: Update composer dependency list and remove unneeded cast --- composer.json | 27 ++- composer.lock | 382 +++++++++++++++++++++++------------- src/Components/Favicons.php | 2 +- 3 files changed, 269 insertions(+), 142 deletions(-) diff --git a/composer.json b/composer.json index 810e353..c9a6b7b 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,11 @@ "php": "^8.4" }, "require-dev": { - "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^13.2", + "phpstan/extension-installer": "*", + "phpstan/phpstan": "^2.3", + "phpstan/phpstan-strict-rules": "^2.1", + "phpunit/phpunit": "^13.4", + "spaze/phpstan-disallowed-calls": "^4.15", "squizlabs/php_codesniffer": "^4.0" }, "autoload": { @@ -28,14 +31,22 @@ } }, "config": { - "preferred-install": "dist" + "preferred-install": "dist", + "allow-plugins": { + "phpstan/extension-installer": true + } }, "prefer-stable": true, "scripts": { - "prod": "@production", - "production": ["@style", "@stan", "@test"], - "style": "./vendor/bin/phpcs --standard=phpcs.xml -p ./src", - "stan": "./vendor/bin/phpstan analyze", - "test": "./vendor/bin/phpunit --display-all-issues" + "prod": "@check", + "production": "@check", + "check": [ + "@check:style", + "@check:analysis", + "@check:test" + ], + "check:style": "./vendor/bin/phpcs --standard=phpcs.xml -p ./src", + "check:analysis": "./vendor/bin/phpstan analyze", + "check:test": "./vendor/bin/phpunit --display-all-issues" } } diff --git a/composer.lock b/composer.lock index 4dc3e36..28dfb69 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b0dda89cb77a498539df68b41a4d0ad1", + "content-hash": "1b47aecd8a2142f9c2aeea89bdafae6a", "packages": [], "packages-dev": [ { @@ -69,16 +69,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.8.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { @@ -120,9 +120,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "phar-io/manifest", @@ -242,13 +242,61 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, { "name": "phpstan/phpstan", - "version": "2.2.9", + "version": "2.3.5", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", - "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/645a1bd6bead15fc1b8474137467961d0fa30a79", + "reference": "645a1bd6bead15fc1b8474137467961d0fa30a79", "shasum": "" }, "require": { @@ -304,20 +352,72 @@ "type": "github" } ], - "time": "2026-08-22T07:38:16+00:00" + "time": "2026-11-03T12:14:22+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "fe4a0afae773eedf178fab17ed9361c0061d2804" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/fe4a0afae773eedf178fab17ed9361c0061d2804", + "reference": "fe4a0afae773eedf178fab17ed9361c0061d2804", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.3" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.1.1" + }, + "time": "2026-11-02T08:16:15+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.3.1", + "version": "14.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "6ce313bb110384148d1dc7695a99175f59529069" + "reference": "2f122b9b48af71ad9b4448368febc26af86534c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6ce313bb110384148d1dc7695a99175f59529069", - "reference": "6ce313bb110384148d1dc7695a99175f59529069", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2f122b9b48af71ad9b4448368febc26af86534c9", + "reference": "2f122b9b48af71ad9b4448368febc26af86534c9", "shasum": "" }, "require": { @@ -325,18 +425,17 @@ "ext-libxml": "*", "ext-mbstring": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.8.0", + "nikic/php-parser": "^5.9.0", "php": ">=8.4", - "phpunit/php-text-template": "^6.0", + "phpunit/php-text-template": "^6.0.1", "sebastian/complexity": "^6.0", - "sebastian/environment": "^9.3.2", - "sebastian/git-state": "^1.0", + "sebastian/environment": "^9.3.3", + "sebastian/git-state": "^1.1.0", "sebastian/lines-of-code": "^5.0.2", - "sebastian/version": "^7.0", - "theseer/tokenizer": "^2.0.1" + "sebastian/version": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.3.1" + "phpunit/phpunit": "^13.4.6" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -345,7 +444,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "14.3.x-dev" + "dev-main": "14.5.x-dev" } }, "autoload": { @@ -374,7 +473,7 @@ "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/14.3.1" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.5.2" }, "funding": [ { @@ -394,27 +493,27 @@ "type": "tidelift" } ], - "time": "2026-08-16T05:23:47+00:00" + "time": "2026-11-09T05:24:08+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "7.0.1", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3ccaa29123548190af12fee7af078dcd7f3ddfab" + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3ccaa29123548190af12fee7af078dcd7f3ddfab", - "reference": "3ccaa29123548190af12fee7af078dcd7f3ddfab", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.3.0" + "phpunit/phpunit": "^13.3.1" }, "type": "library", "extra": { @@ -447,7 +546,7 @@ "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/7.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.2" }, "funding": [ { @@ -467,7 +566,7 @@ "type": "tidelift" } ], - "time": "2026-08-10T06:43:12+00:00" + "time": "2026-08-25T14:47:43+00:00" }, { "name": "phpunit/php-invoker", @@ -547,23 +646,23 @@ }, { "name": "phpunit/php-text-template", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + "reference": "97107691cb2b6c743b5e0c44be19e983cb337a02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/97107691cb2b6c743b5e0c44be19e983cb337a02", + "reference": "97107691cb2b6c743b5e0c44be19e983cb337a02", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.6" }, "type": "library", "extra": { @@ -595,7 +694,7 @@ "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/6.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.1" }, "funding": [ { @@ -615,7 +714,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:36:37+00:00" + "time": "2026-10-03T04:54:24+00:00" }, { "name": "phpunit/php-timer", @@ -691,16 +790,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.3.1", + "version": "13.4.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fc024931d6ad047404e9d86536735923fe63a06b" + "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc024931d6ad047404e9d86536735923fe63a06b", - "reference": "fc024931d6ad047404e9d86536735923fe63a06b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4821376960d0e1e5a7b7b700955ebcab33ecd71b", + "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b", "shasum": "" }, "require": { @@ -714,23 +813,23 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.3", - "phpunit/php-file-iterator": "^7.0.1", + "phpunit/php-code-coverage": "^14.5.0", + "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-invoker": "^7.0.0", - "phpunit/php-text-template": "^6.0.0", + "phpunit/php-text-template": "^6.0.1", "phpunit/php-timer": "^9.0.0", "sebastian/cli-parser": "^5.0.1", "sebastian/comparator": "^8.4", - "sebastian/diff": "^9.0", - "sebastian/environment": "^9.3.2", + "sebastian/diff": "^9.0.1", + "sebastian/environment": "^9.3.3", "sebastian/exporter": "^8.2.1", "sebastian/file-filter": "^1.0", - "sebastian/git-state": "^1.0", + "sebastian/git-state": "^1.1", "sebastian/global-state": "^9.0.1", "sebastian/object-enumerator": "^8.1.0", "sebastian/recursion-context": "^8.0.1", "sebastian/type": "^7.0.2", - "sebastian/version": "^7.0.0", + "sebastian/version": "^7.0.1", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -739,7 +838,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "13.3-dev" + "dev-main": "13.4-dev" } }, "autoload": { @@ -771,7 +870,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.1" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.6" }, "funding": [ { @@ -779,7 +878,7 @@ "type": "other" } ], - "time": "2026-08-13T13:14:23+00:00" + "time": "2026-11-02T07:12:28+00:00" }, { "name": "sebastian/cli-parser", @@ -1014,24 +1113,24 @@ }, { "name": "sebastian/diff", - "version": "9.0.0", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.2", - "symfony/process": "^7.4.13" + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { @@ -1069,7 +1168,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1" }, "funding": [ { @@ -1089,27 +1188,27 @@ "type": "tidelift" } ], - "time": "2026-06-05T03:04:51+00:00" + "time": "2026-08-25T15:38:55+00:00" }, { "name": "sebastian/environment", - "version": "9.3.2", + "version": "9.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + "reference": "d4dd10267a11055066a2a78ae04583d8ddd4f595" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/d4dd10267a11055066a2a78ae04583d8ddd4f595", + "reference": "d4dd10267a11055066a2a78ae04583d8ddd4f595", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.1.11" + "phpunit/phpunit": "^13.3.6" }, "suggest": { "ext-posix": "*" @@ -1145,7 +1244,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.3" }, "funding": [ { @@ -1165,7 +1264,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:41:38+00:00" + "time": "2026-10-01T12:37:56+00:00" }, { "name": "sebastian/exporter", @@ -1328,28 +1427,28 @@ }, { "name": "sebastian/git-state", - "version": "1.0.0", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/git-state.git", - "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + "reference": "31a565f463686f0eefa85f899549256bf5805985" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", - "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/31a565f463686f0eefa85f899549256bf5805985", + "reference": "31a565f463686f0eefa85f899549256bf5805985", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.4.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-main": "1.1-dev" } }, "autoload": { @@ -1373,7 +1472,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/git-state/issues", "security": "https://github.com/sebastianbergmann/git-state/security/policy", - "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + "source": "https://github.com/sebastianbergmann/git-state/tree/1.1.0" }, "funding": [ { @@ -1393,7 +1492,7 @@ "type": "tidelift" } ], - "time": "2026-03-21T12:54:28+00:00" + "time": "2026-10-21T05:59:52+00:00" }, { "name": "sebastian/global-state", @@ -1823,16 +1922,16 @@ }, { "name": "sebastian/version", - "version": "7.0.0", + "version": "7.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + "reference": "08e41d0c7e146bfc8cef7e63989b1294453a6d6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/08e41d0c7e146bfc8cef7e63989b1294453a6d6a", + "reference": "08e41d0c7e146bfc8cef7e63989b1294453a6d6a", "shasum": "" }, "require": { @@ -1841,7 +1940,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -1865,7 +1964,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/7.1.0" }, "funding": [ { @@ -1885,7 +1984,74 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:52:52+00:00" + "time": "2026-11-09T06:31:27+00:00" + }, + { + "name": "spaze/phpstan-disallowed-calls", + "version": "v4.15.0", + "source": { + "type": "git", + "url": "https://github.com/spaze/phpstan-disallowed-calls.git", + "reference": "388075908225e22c46b9622177d1629203d41c1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spaze/phpstan-disallowed-calls/zipball/388075908225e22c46b9622177d1629203d41c1f", + "reference": "388075908225e22c46b9622177d1629203d41c1f", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^1.12.6 || ^2.0" + }, + "require-dev": { + "nette/neon": "^3.3.1", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^1.2 || ^2.0", + "phpunit/phpunit": "^9.6 || ^10.1 || ^11.0 || ^12.0 || ^13.0", + "shipmonk/dead-code-detector": "^1.1", + "spaze/coding-standard": "^1.8" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Spaze\\PHPStan\\Rules\\Disallowed\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michal Špaček", + "email": "mail@michalspacek.cz", + "homepage": "https://www.michalspacek.cz" + } + ], + "description": "PHPStan rules to detect disallowed method & function calls, constant, namespace, attribute, property & superglobal usages, with powerful rules to re-allow a call or a usage in places where it should be allowed.", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/spaze/phpstan-disallowed-calls/issues", + "source": "https://github.com/spaze/phpstan-disallowed-calls/tree/v4.15.0" + }, + "funding": [ + { + "url": "https://github.com/spaze", + "type": "github" + } + ], + "time": "2026-10-13T00:31:30+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -2022,56 +2188,6 @@ } ], "time": "2024-10-20T05:08:20+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" } ], "aliases": [], diff --git a/src/Components/Favicons.php b/src/Components/Favicons.php index 567d1f5..05f7ff8 100644 --- a/src/Components/Favicons.php +++ b/src/Components/Favicons.php @@ -184,6 +184,6 @@ public function __toString(): string 'content ' . $this->appName() ); } - return (string) implode('', $elements); + return implode('', $elements); } } From 085136362b9600fceb409763abe623295fb3f4dc Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 10:34:30 -0500 Subject: [PATCH 02/27] feat: Docblock builder for elements --- bin/generate-element-docblock.php | 131 +++++++++++++++++++++++++ composer.json | 3 +- docblocks/elements.txt | 156 ++++++++++++++++++++++++++++++ phpstan.neon | 2 +- 4 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 bin/generate-element-docblock.php create mode 100644 docblocks/elements.txt diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php new file mode 100644 index 0000000..542450e --- /dev/null +++ b/bin/generate-element-docblock.php @@ -0,0 +1,131 @@ + $specs) { + if ($specs === []) { + $groups[-1][] = $name; + continue; + } + $last = -1; + foreach ($specs as $spec) { + $index = array_search((string) $spec, $eraOrder, true); + if ($index !== false && $index > $last) { + $last = $index; + } + } + $groups[$last][] = $name; +} + +krsort($groups); // newest last-appearance first; -1 (unspecified) lands last + +$lines = []; +$lines[] = '/**'; +$lines[] = ' * Magic static element factories. Generated from w3c/elements-of-html'; +$lines[] = sprintf( + ' * elements.json (retrieved %s): %d entries, %d unique elements after', + date('Y-m-d'), + count($entries), + count($specsByName), +); +$lines[] = sprintf( + ' * collapsing the %d input[type=...] variants into input. Sectioned by', + $variantCount, +); +$lines[] = ' * era: current elements first, then deprecated groups by when they were'; +$lines[] = ' * dropped, each group alphabetical.'; +$lines[] = ' *'; +$lines[] = ' * Element semantics per the WHATWG HTML living standard:'; +$lines[] = ' * https://html.spec.whatwg.org'; +$lines[] = ' *'; + +$first = true; +foreach ($groups as $lastIndex => $names) { + sort($names); + if (!$first) { + $lines[] = ' *'; + } + $first = false; + + if ($lastIndex === -1) { + $lines[] = ' * No spec data in source (verify before relying on these):'; + } elseif ($eraOrder[$lastIndex] === $current) { + $lines[] = sprintf(' * Available in %s (current):', $current); + } else { + // The XHTML 1.x line runs parallel to HTML 4.01; an element dropped + // after X1.0/X1.1 was typically still available in 4.01, so list both. + $lastAvailable = [$eraOrder[$lastIndex]]; + if (str_starts_with($eraOrder[$lastIndex], 'X1')) { + array_unshift($lastAvailable, '4.01'); + } + $lines[] = sprintf( + ' * Deprecated in %s; last available in %s:', + $eraOrder[$lastIndex + 1], + implode(', ', $lastAvailable), + ); + } + foreach ($names as $name) { + $lines[] = sprintf(' * @method static static %s(string|\Stringable ...$content)', $name); + } +} +$lines[] = ' */'; + +$docblock = implode("\n", $lines) . "\n"; + +$target = $argv[1] ?? null; +if ($target === null) { + echo $docblock; + exit(0); +} + +if (file_put_contents(__DIR__ . '/../docblocks/' . $target, $docblock) === false) { + fwrite(STDERR, "Could not write: {$target}\n"); + exit(1); +} + +fwrite(STDOUT, sprintf("Wrote %d lines to %s\n", count($lines), $target)); diff --git a/composer.json b/composer.json index c9a6b7b..d10d05c 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ ], "check:style": "./vendor/bin/phpcs --standard=phpcs.xml -p ./src", "check:analysis": "./vendor/bin/phpstan analyze", - "check:test": "./vendor/bin/phpunit --display-all-issues" + "check:test": "./vendor/bin/phpunit --display-all-issues", + "build:docblocks": "php ./bin/generate-element-docblock.php elements.txt " } } diff --git a/docblocks/elements.txt b/docblocks/elements.txt new file mode 100644 index 0000000..30abaff --- /dev/null +++ b/docblocks/elements.txt @@ -0,0 +1,156 @@ +/** + * Magic static element factories. Generated from w3c/elements-of-html + * elements.json (retrieved 2026-11-10): 154 entries, 132 unique elements after + * collapsing the 22 input[type=...] variants into input. Sectioned by + * era: current elements first, then deprecated groups by when they were + * dropped, each group alphabetical. + * + * Element semantics per the WHATWG HTML living standard: + * https://html.spec.whatwg.org + * + * Available in 5.2 (current): + * @method static static a(string|\Stringable ...$content) + * @method static static abbr(string|\Stringable ...$content) + * @method static static address(string|\Stringable ...$content) + * @method static static area(string|\Stringable ...$content) + * @method static static article(string|\Stringable ...$content) + * @method static static aside(string|\Stringable ...$content) + * @method static static audio(string|\Stringable ...$content) + * @method static static b(string|\Stringable ...$content) + * @method static static base(string|\Stringable ...$content) + * @method static static bdi(string|\Stringable ...$content) + * @method static static bdo(string|\Stringable ...$content) + * @method static static blockquote(string|\Stringable ...$content) + * @method static static body(string|\Stringable ...$content) + * @method static static br(string|\Stringable ...$content) + * @method static static button(string|\Stringable ...$content) + * @method static static canvas(string|\Stringable ...$content) + * @method static static caption(string|\Stringable ...$content) + * @method static static cite(string|\Stringable ...$content) + * @method static static code(string|\Stringable ...$content) + * @method static static col(string|\Stringable ...$content) + * @method static static colgroup(string|\Stringable ...$content) + * @method static static data(string|\Stringable ...$content) + * @method static static datalist(string|\Stringable ...$content) + * @method static static dd(string|\Stringable ...$content) + * @method static static del(string|\Stringable ...$content) + * @method static static details(string|\Stringable ...$content) + * @method static static dfn(string|\Stringable ...$content) + * @method static static dialog(string|\Stringable ...$content) + * @method static static div(string|\Stringable ...$content) + * @method static static dl(string|\Stringable ...$content) + * @method static static dt(string|\Stringable ...$content) + * @method static static em(string|\Stringable ...$content) + * @method static static embed(string|\Stringable ...$content) + * @method static static fieldset(string|\Stringable ...$content) + * @method static static figcaption(string|\Stringable ...$content) + * @method static static figure(string|\Stringable ...$content) + * @method static static footer(string|\Stringable ...$content) + * @method static static form(string|\Stringable ...$content) + * @method static static h1(string|\Stringable ...$content) + * @method static static h2(string|\Stringable ...$content) + * @method static static h3(string|\Stringable ...$content) + * @method static static h4(string|\Stringable ...$content) + * @method static static h5(string|\Stringable ...$content) + * @method static static h6(string|\Stringable ...$content) + * @method static static head(string|\Stringable ...$content) + * @method static static header(string|\Stringable ...$content) + * @method static static hr(string|\Stringable ...$content) + * @method static static html(string|\Stringable ...$content) + * @method static static i(string|\Stringable ...$content) + * @method static static iframe(string|\Stringable ...$content) + * @method static static img(string|\Stringable ...$content) + * @method static static input(string|\Stringable ...$content) + * @method static static ins(string|\Stringable ...$content) + * @method static static kbd(string|\Stringable ...$content) + * @method static static keygen(string|\Stringable ...$content) + * @method static static label(string|\Stringable ...$content) + * @method static static legend(string|\Stringable ...$content) + * @method static static li(string|\Stringable ...$content) + * @method static static link(string|\Stringable ...$content) + * @method static static main(string|\Stringable ...$content) + * @method static static map(string|\Stringable ...$content) + * @method static static mark(string|\Stringable ...$content) + * @method static static menu(string|\Stringable ...$content) + * @method static static menuitem(string|\Stringable ...$content) + * @method static static meta(string|\Stringable ...$content) + * @method static static meter(string|\Stringable ...$content) + * @method static static nav(string|\Stringable ...$content) + * @method static static noscript(string|\Stringable ...$content) + * @method static static object(string|\Stringable ...$content) + * @method static static ol(string|\Stringable ...$content) + * @method static static optgroup(string|\Stringable ...$content) + * @method static static option(string|\Stringable ...$content) + * @method static static output(string|\Stringable ...$content) + * @method static static p(string|\Stringable ...$content) + * @method static static param(string|\Stringable ...$content) + * @method static static picture(string|\Stringable ...$content) + * @method static static pre(string|\Stringable ...$content) + * @method static static progress(string|\Stringable ...$content) + * @method static static q(string|\Stringable ...$content) + * @method static static rb(string|\Stringable ...$content) + * @method static static rp(string|\Stringable ...$content) + * @method static static rt(string|\Stringable ...$content) + * @method static static ruby(string|\Stringable ...$content) + * @method static static s(string|\Stringable ...$content) + * @method static static samp(string|\Stringable ...$content) + * @method static static script(string|\Stringable ...$content) + * @method static static section(string|\Stringable ...$content) + * @method static static select(string|\Stringable ...$content) + * @method static static small(string|\Stringable ...$content) + * @method static static source(string|\Stringable ...$content) + * @method static static span(string|\Stringable ...$content) + * @method static static strong(string|\Stringable ...$content) + * @method static static style(string|\Stringable ...$content) + * @method static static sub(string|\Stringable ...$content) + * @method static static summary(string|\Stringable ...$content) + * @method static static sup(string|\Stringable ...$content) + * @method static static table(string|\Stringable ...$content) + * @method static static tbody(string|\Stringable ...$content) + * @method static static td(string|\Stringable ...$content) + * @method static static template(string|\Stringable ...$content) + * @method static static textarea(string|\Stringable ...$content) + * @method static static tfoot(string|\Stringable ...$content) + * @method static static th(string|\Stringable ...$content) + * @method static static thead(string|\Stringable ...$content) + * @method static static time(string|\Stringable ...$content) + * @method static static title(string|\Stringable ...$content) + * @method static static tr(string|\Stringable ...$content) + * @method static static track(string|\Stringable ...$content) + * @method static static u(string|\Stringable ...$content) + * @method static static ul(string|\Stringable ...$content) + * @method static static var(string|\Stringable ...$content) + * @method static static video(string|\Stringable ...$content) + * @method static static wbr(string|\Stringable ...$content) + * + * Deprecated in 5; last available in 4.01, X1.1: + * @method static static acronym(string|\Stringable ...$content) + * @method static static big(string|\Stringable ...$content) + * @method static static tt(string|\Stringable ...$content) + * + * Deprecated in X1.1; last available in 4.01, X1.0: + * @method static static applet(string|\Stringable ...$content) + * @method static static basefont(string|\Stringable ...$content) + * @method static static center(string|\Stringable ...$content) + * @method static static dir(string|\Stringable ...$content) + * @method static static font(string|\Stringable ...$content) + * @method static static isindex(string|\Stringable ...$content) + * @method static static noframes(string|\Stringable ...$content) + * @method static static strike(string|\Stringable ...$content) + * + * Deprecated in X1.0; last available in 4.01: + * @method static static frame(string|\Stringable ...$content) + * @method static static frameset(string|\Stringable ...$content) + * + * Deprecated in 4.01; last available in 3.2: + * @method static static plaintext(string|\Stringable ...$content) + * @method static static xmp(string|\Stringable ...$content) + * + * Deprecated in 3.2; last available in 2.0: + * @method static static nextid(string|\Stringable ...$content) + * + * No spec data in source (verify before relying on these): + * @method static static hgroup(string|\Stringable ...$content) + * @method static static rbc(string|\Stringable ...$content) + * @method static static rtc(string|\Stringable ...$content) + */ diff --git a/phpstan.neon b/phpstan.neon index d305d34..94a2ecd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 9 + level: 10 paths: - src ignoreErrors: From 13effbfdc33dffffd32fb0dcd62d4532e96d746e Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 10:37:22 -0500 Subject: [PATCH 03/27] feat: Element passes PHPStan L10, no ignored errors, fixed incorrect casing --- phpstan.neon | 2 - src/Components/Favicons.php | 2 +- src/Element.php | 156 ++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 94a2ecd..ec91830 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,5 +2,3 @@ parameters: level: 10 paths: - src - ignoreErrors: - - '#Call to an undefined static method Eightfold\\HtmlBuilder\\Element::(.*)#' diff --git a/src/Components/Favicons.php b/src/Components/Favicons.php index 05f7ff8..321410a 100644 --- a/src/Components/Favicons.php +++ b/src/Components/Favicons.php @@ -155,7 +155,7 @@ public function __toString(): string ]; if ($this->hasPath()) { - $elements[] = Element::link()->omitendTag()->props( + $elements[] = Element::link()->omitEndTag()->props( 'rel shortcut icon', 'href ' . $this->path() . '/favicon.ico' ); diff --git a/src/Element.php b/src/Element.php index 5d2512f..6f8b3c7 100644 --- a/src/Element.php +++ b/src/Element.php @@ -7,6 +7,162 @@ use Stringable; +/** + * Magic static element factories. Generated from w3c/elements-of-html + * elements.json (retrieved 2026-11-10): 154 entries, 132 unique elements after + * collapsing the 22 input[type=...] variants into input. Sectioned by + * era: current elements first, then deprecated groups by when they were + * dropped, each group alphabetical. + * + * Element semantics per the WHATWG HTML living standard: + * https://html.spec.whatwg.org + * + * Available in 5.2 (current): + * @method static static a(string|\Stringable ...$content) + * @method static static abbr(string|\Stringable ...$content) + * @method static static address(string|\Stringable ...$content) + * @method static static area(string|\Stringable ...$content) + * @method static static article(string|\Stringable ...$content) + * @method static static aside(string|\Stringable ...$content) + * @method static static audio(string|\Stringable ...$content) + * @method static static b(string|\Stringable ...$content) + * @method static static base(string|\Stringable ...$content) + * @method static static bdi(string|\Stringable ...$content) + * @method static static bdo(string|\Stringable ...$content) + * @method static static blockquote(string|\Stringable ...$content) + * @method static static body(string|\Stringable ...$content) + * @method static static br(string|\Stringable ...$content) + * @method static static button(string|\Stringable ...$content) + * @method static static canvas(string|\Stringable ...$content) + * @method static static caption(string|\Stringable ...$content) + * @method static static cite(string|\Stringable ...$content) + * @method static static code(string|\Stringable ...$content) + * @method static static col(string|\Stringable ...$content) + * @method static static colgroup(string|\Stringable ...$content) + * @method static static data(string|\Stringable ...$content) + * @method static static datalist(string|\Stringable ...$content) + * @method static static dd(string|\Stringable ...$content) + * @method static static del(string|\Stringable ...$content) + * @method static static details(string|\Stringable ...$content) + * @method static static dfn(string|\Stringable ...$content) + * @method static static dialog(string|\Stringable ...$content) + * @method static static div(string|\Stringable ...$content) + * @method static static dl(string|\Stringable ...$content) + * @method static static dt(string|\Stringable ...$content) + * @method static static em(string|\Stringable ...$content) + * @method static static embed(string|\Stringable ...$content) + * @method static static fieldset(string|\Stringable ...$content) + * @method static static figcaption(string|\Stringable ...$content) + * @method static static figure(string|\Stringable ...$content) + * @method static static footer(string|\Stringable ...$content) + * @method static static form(string|\Stringable ...$content) + * @method static static h1(string|\Stringable ...$content) + * @method static static h2(string|\Stringable ...$content) + * @method static static h3(string|\Stringable ...$content) + * @method static static h4(string|\Stringable ...$content) + * @method static static h5(string|\Stringable ...$content) + * @method static static h6(string|\Stringable ...$content) + * @method static static head(string|\Stringable ...$content) + * @method static static header(string|\Stringable ...$content) + * @method static static hr(string|\Stringable ...$content) + * @method static static html(string|\Stringable ...$content) + * @method static static i(string|\Stringable ...$content) + * @method static static iframe(string|\Stringable ...$content) + * @method static static img(string|\Stringable ...$content) + * @method static static input(string|\Stringable ...$content) + * @method static static ins(string|\Stringable ...$content) + * @method static static kbd(string|\Stringable ...$content) + * @method static static keygen(string|\Stringable ...$content) + * @method static static label(string|\Stringable ...$content) + * @method static static legend(string|\Stringable ...$content) + * @method static static li(string|\Stringable ...$content) + * @method static static link(string|\Stringable ...$content) + * @method static static main(string|\Stringable ...$content) + * @method static static map(string|\Stringable ...$content) + * @method static static mark(string|\Stringable ...$content) + * @method static static menu(string|\Stringable ...$content) + * @method static static menuitem(string|\Stringable ...$content) + * @method static static meta(string|\Stringable ...$content) + * @method static static meter(string|\Stringable ...$content) + * @method static static nav(string|\Stringable ...$content) + * @method static static noscript(string|\Stringable ...$content) + * @method static static object(string|\Stringable ...$content) + * @method static static ol(string|\Stringable ...$content) + * @method static static optgroup(string|\Stringable ...$content) + * @method static static option(string|\Stringable ...$content) + * @method static static output(string|\Stringable ...$content) + * @method static static p(string|\Stringable ...$content) + * @method static static param(string|\Stringable ...$content) + * @method static static picture(string|\Stringable ...$content) + * @method static static pre(string|\Stringable ...$content) + * @method static static progress(string|\Stringable ...$content) + * @method static static q(string|\Stringable ...$content) + * @method static static rb(string|\Stringable ...$content) + * @method static static rp(string|\Stringable ...$content) + * @method static static rt(string|\Stringable ...$content) + * @method static static ruby(string|\Stringable ...$content) + * @method static static s(string|\Stringable ...$content) + * @method static static samp(string|\Stringable ...$content) + * @method static static script(string|\Stringable ...$content) + * @method static static section(string|\Stringable ...$content) + * @method static static select(string|\Stringable ...$content) + * @method static static small(string|\Stringable ...$content) + * @method static static source(string|\Stringable ...$content) + * @method static static span(string|\Stringable ...$content) + * @method static static strong(string|\Stringable ...$content) + * @method static static style(string|\Stringable ...$content) + * @method static static sub(string|\Stringable ...$content) + * @method static static summary(string|\Stringable ...$content) + * @method static static sup(string|\Stringable ...$content) + * @method static static table(string|\Stringable ...$content) + * @method static static tbody(string|\Stringable ...$content) + * @method static static td(string|\Stringable ...$content) + * @method static static template(string|\Stringable ...$content) + * @method static static textarea(string|\Stringable ...$content) + * @method static static tfoot(string|\Stringable ...$content) + * @method static static th(string|\Stringable ...$content) + * @method static static thead(string|\Stringable ...$content) + * @method static static time(string|\Stringable ...$content) + * @method static static title(string|\Stringable ...$content) + * @method static static tr(string|\Stringable ...$content) + * @method static static track(string|\Stringable ...$content) + * @method static static u(string|\Stringable ...$content) + * @method static static ul(string|\Stringable ...$content) + * @method static static var(string|\Stringable ...$content) + * @method static static video(string|\Stringable ...$content) + * @method static static wbr(string|\Stringable ...$content) + * + * Deprecated in 5; last available in 4.01, X1.1: + * @method static static acronym(string|\Stringable ...$content) + * @method static static big(string|\Stringable ...$content) + * @method static static tt(string|\Stringable ...$content) + * + * Deprecated in X1.1; last available in 4.01, X1.0: + * @method static static applet(string|\Stringable ...$content) + * @method static static basefont(string|\Stringable ...$content) + * @method static static center(string|\Stringable ...$content) + * @method static static dir(string|\Stringable ...$content) + * @method static static font(string|\Stringable ...$content) + * @method static static isindex(string|\Stringable ...$content) + * @method static static noframes(string|\Stringable ...$content) + * @method static static strike(string|\Stringable ...$content) + * + * Deprecated in X1.0; last available in 4.01: + * @method static static frame(string|\Stringable ...$content) + * @method static static frameset(string|\Stringable ...$content) + * + * Deprecated in 4.01; last available in 3.2: + * @method static static plaintext(string|\Stringable ...$content) + * @method static static xmp(string|\Stringable ...$content) + * + * Deprecated in 3.2; last available in 2.0: + * @method static static nextid(string|\Stringable ...$content) + * + * No spec data in source (verify before relying on these): + * @method static static hgroup(string|\Stringable ...$content) + * @method static static rbc(string|\Stringable ...$content) + * @method static static rtc(string|\Stringable ...$content) + */ class Element implements PropertyInterface { /** From e3bb79c03d7787304ee8ec999f98e4f978c3b02e Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 10:49:18 -0500 Subject: [PATCH 04/27] feat: EmptyElement added only used in one test case --- src/Element.php | 4 +- src/EmptyElement.php | 257 ++++++++++++++++++++++++++++++++++ tests/ControlContractTest.php | 3 +- 3 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 src/EmptyElement.php diff --git a/src/Element.php b/src/Element.php index 6f8b3c7..a30517c 100644 --- a/src/Element.php +++ b/src/Element.php @@ -222,7 +222,7 @@ public static function __callStatic(string $name, array $content = []): static /** * @param array $content - * @param string[] $properties + * @param list $properties */ final private function __construct( private string $name, @@ -234,7 +234,7 @@ final private function __construct( public function props(string ...$properties): static { - $this->properties = $properties; + $this->properties = array_values($properties); return $this; } diff --git a/src/EmptyElement.php b/src/EmptyElement.php new file mode 100644 index 0000000..ed1818f --- /dev/null +++ b/src/EmptyElement.php @@ -0,0 +1,257 @@ + $properties + */ + public static function __callStatic(string $name, array $properties = []): static + { + return new static($name, $properties); + } + + /** + * @param list $properties + */ + final private function __construct( + private string $name, + private array $properties = [], + ) { + } + + public function props(string ...$properties): static + { + $this->properties = array_values($properties); + return $this; + } + + public function prop(string $prop): static + { + $this->properties[] = $prop; + return $this; + } + + public function toString(): string + { + return (string) Element::create($this->name)->omitEndTag() + ->props(...$this->properties); + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/tests/ControlContractTest.php b/tests/ControlContractTest.php index 6455df6..b36f7f0 100644 --- a/tests/ControlContractTest.php +++ b/tests/ControlContractTest.php @@ -12,6 +12,7 @@ use Dom\HTMLDocument; use Dom\XPath; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; use Eightfold\HtmlBuilder\Forms\EmailInput; use Eightfold\HtmlBuilder\Forms\Password; use Eightfold\HtmlBuilder\Forms\Select; @@ -75,7 +76,7 @@ public function element_still_skips_empty_properties_without_clearing_previous_v { parent::assertSame( '', - (string) Element::input()->omitEndTag()->props('value existing', 'value ', 'value') + (string) EmptyElement::input('value existing', 'value ', 'value') ); } From ad8eb8e1c497361d7df9b87c0172a97345eebaa1 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 10:53:56 -0500 Subject: [PATCH 05/27] fix: GitHub workflows --- .github/workflows/php84.yml | 6 +++--- .github/workflows/php85.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/php84.yml b/.github/workflows/php84.yml index 34af196..3a8169d 100644 --- a/.github/workflows/php84.yml +++ b/.github/workflows/php84.yml @@ -25,10 +25,10 @@ jobs: run: composer install --prefer-dist --no-progress - name: Run style check - run: composer run style + run: composer run check:style - name: Run static analyzer - run: composer run stan + run: composer run check:analysis - name: Run tests - run: composer run test + run: composer run check:test diff --git a/.github/workflows/php85.yml b/.github/workflows/php85.yml index 7ac8c7e..5704d92 100644 --- a/.github/workflows/php85.yml +++ b/.github/workflows/php85.yml @@ -25,10 +25,10 @@ jobs: run: composer install --prefer-dist --no-progress - name: Run style check - run: composer run style + run: composer run check:style - name: Run static analyzer - run: composer run stan + run: composer run check:analysis - name: Run tests - run: composer run test + run: composer run check:test From 4b006c1a3fd46f3a2fc12279b0374b57d6ae5328 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 11:06:04 -0500 Subject: [PATCH 06/27] feat: Empty element docblock bin --- bin/generate-empty-element-docblock.php | 70 +++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 bin/generate-empty-element-docblock.php diff --git a/bin/generate-empty-element-docblock.php b/bin/generate-empty-element-docblock.php new file mode 100644 index 0000000..c2d8d58 --- /dev/null +++ b/bin/generate-empty-element-docblock.php @@ -0,0 +1,70 @@ +No end tag. +$pattern = '/>No Date: Tue, 10 Nov 2026 11:15:56 -0500 Subject: [PATCH 07/27] red: Has failing empty element test --- bin/generate-empty-element-docblock.php | 2 +- composer.json | 2 +- .../{elements.txt => elements-generated.txt} | 0 docblocks/empty-elements-generated.txt | 20 ++ src/EmptyElement.php | 212 ++---------------- tests/EmptyElementBaselineTest.php | 24 ++ 6 files changed, 63 insertions(+), 197 deletions(-) rename docblocks/{elements.txt => elements-generated.txt} (100%) create mode 100644 docblocks/empty-elements-generated.txt create mode 100644 tests/EmptyElementBaselineTest.php diff --git a/bin/generate-empty-element-docblock.php b/bin/generate-empty-element-docblock.php index c2d8d58..640f884 100644 --- a/bin/generate-empty-element-docblock.php +++ b/bin/generate-empty-element-docblock.php @@ -62,7 +62,7 @@ exit(0); } -if (file_put_contents($target, $docblock) === false) { +if (file_put_contents(__DIR__ . '/../docblocks/' . $target, $docblock) === false) { fwrite(STDERR, "Could not write: {$target}\n"); exit(1); } diff --git a/composer.json b/composer.json index d10d05c..c6a498a 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,6 @@ "check:style": "./vendor/bin/phpcs --standard=phpcs.xml -p ./src", "check:analysis": "./vendor/bin/phpstan analyze", "check:test": "./vendor/bin/phpunit --display-all-issues", - "build:docblocks": "php ./bin/generate-element-docblock.php elements.txt " + "build:docblocks": "php ./bin/generate-element-docblock.php elements-generated.txt && php ./bin/generate-empty-element-docblock.php empty-elements-generated.txt" } } diff --git a/docblocks/elements.txt b/docblocks/elements-generated.txt similarity index 100% rename from docblocks/elements.txt rename to docblocks/elements-generated.txt diff --git a/docblocks/empty-elements-generated.txt b/docblocks/empty-elements-generated.txt new file mode 100644 index 0000000..39a9ac6 --- /dev/null +++ b/docblocks/empty-elements-generated.txt @@ -0,0 +1,20 @@ +/** + * Magic static void-element factories. Generated from the WHATWG HTML + * living standard (https://html.spec.whatwg.org/, retrieved 2026-11-10): + * 13 void elements, derived from each element's "Tag omission in + * text/html: No end tag." entry. Alphabetical. + * + * @method static static area(string ...$properties) + * @method static static base(string ...$properties) + * @method static static br(string ...$properties) + * @method static static col(string ...$properties) + * @method static static embed(string ...$properties) + * @method static static hr(string ...$properties) + * @method static static img(string ...$properties) + * @method static static input(string ...$properties) + * @method static static link(string ...$properties) + * @method static static meta(string ...$properties) + * @method static static source(string ...$properties) + * @method static static track(string ...$properties) + * @method static static wbr(string ...$properties) + */ diff --git a/src/EmptyElement.php b/src/EmptyElement.php index ed1818f..4b5203c 100644 --- a/src/EmptyElement.php +++ b/src/EmptyElement.php @@ -10,205 +10,27 @@ use Stringable; /** - * Magic static element factories. Generated from w3c/elements-of-html - * elements.json (retrieved 2026-11-10): 154 entries, 132 unique elements after - * collapsing the 22 input[type=...] variants into input. Sectioned by - * era: current elements first, then deprecated groups by when they were - * dropped, each group alphabetical. + * Magic static void-element factories. Generated from the WHATWG HTML + * living standard (https://html.spec.whatwg.org/, retrieved 2026-11-10): + * 13 void elements, derived from each element's "Tag omission in + * text/html: No end tag." entry. Alphabetical. * - * Element semantics per the WHATWG HTML living standard: - * https://html.spec.whatwg.org - * - * Available in 5.2 (current): - * @method static static a(string|\Stringable ...$content) - * @method static static abbr(string|\Stringable ...$content) - * @method static static address(string|\Stringable ...$content) - * @method static static area(string|\Stringable ...$content) - * @method static static article(string|\Stringable ...$content) - * @method static static aside(string|\Stringable ...$content) - * @method static static audio(string|\Stringable ...$content) - * @method static static b(string|\Stringable ...$content) - * @method static static base(string|\Stringable ...$content) - * @method static static bdi(string|\Stringable ...$content) - * @method static static bdo(string|\Stringable ...$content) - * @method static static blockquote(string|\Stringable ...$content) - * @method static static body(string|\Stringable ...$content) - * @method static static br(string|\Stringable ...$content) - * @method static static button(string|\Stringable ...$content) - * @method static static canvas(string|\Stringable ...$content) - * @method static static caption(string|\Stringable ...$content) - * @method static static cite(string|\Stringable ...$content) - * @method static static code(string|\Stringable ...$content) - * @method static static col(string|\Stringable ...$content) - * @method static static colgroup(string|\Stringable ...$content) - * @method static static data(string|\Stringable ...$content) - * @method static static datalist(string|\Stringable ...$content) - * @method static static dd(string|\Stringable ...$content) - * @method static static del(string|\Stringable ...$content) - * @method static static details(string|\Stringable ...$content) - * @method static static dfn(string|\Stringable ...$content) - * @method static static dialog(string|\Stringable ...$content) - * @method static static div(string|\Stringable ...$content) - * @method static static dl(string|\Stringable ...$content) - * @method static static dt(string|\Stringable ...$content) - * @method static static em(string|\Stringable ...$content) - * @method static static embed(string|\Stringable ...$content) - * @method static static fieldset(string|\Stringable ...$content) - * @method static static figcaption(string|\Stringable ...$content) - * @method static static figure(string|\Stringable ...$content) - * @method static static footer(string|\Stringable ...$content) - * @method static static form(string|\Stringable ...$content) - * @method static static h1(string|\Stringable ...$content) - * @method static static h2(string|\Stringable ...$content) - * @method static static h3(string|\Stringable ...$content) - * @method static static h4(string|\Stringable ...$content) - * @method static static h5(string|\Stringable ...$content) - * @method static static h6(string|\Stringable ...$content) - * @method static static head(string|\Stringable ...$content) - * @method static static header(string|\Stringable ...$content) - * @method static static hr(string|\Stringable ...$content) - * @method static static html(string|\Stringable ...$content) - * @method static static i(string|\Stringable ...$content) - * @method static static iframe(string|\Stringable ...$content) - * @method static static img(string|\Stringable ...$content) - * @method static static input(string|\Stringable ...$content) - * @method static static ins(string|\Stringable ...$content) - * @method static static kbd(string|\Stringable ...$content) - * @method static static keygen(string|\Stringable ...$content) - * @method static static label(string|\Stringable ...$content) - * @method static static legend(string|\Stringable ...$content) - * @method static static li(string|\Stringable ...$content) - * @method static static link(string|\Stringable ...$content) - * @method static static main(string|\Stringable ...$content) - * @method static static map(string|\Stringable ...$content) - * @method static static mark(string|\Stringable ...$content) - * @method static static menu(string|\Stringable ...$content) - * @method static static menuitem(string|\Stringable ...$content) - * @method static static meta(string|\Stringable ...$content) - * @method static static meter(string|\Stringable ...$content) - * @method static static nav(string|\Stringable ...$content) - * @method static static noscript(string|\Stringable ...$content) - * @method static static object(string|\Stringable ...$content) - * @method static static ol(string|\Stringable ...$content) - * @method static static optgroup(string|\Stringable ...$content) - * @method static static option(string|\Stringable ...$content) - * @method static static output(string|\Stringable ...$content) - * @method static static p(string|\Stringable ...$content) - * @method static static param(string|\Stringable ...$content) - * @method static static picture(string|\Stringable ...$content) - * @method static static pre(string|\Stringable ...$content) - * @method static static progress(string|\Stringable ...$content) - * @method static static q(string|\Stringable ...$content) - * @method static static rb(string|\Stringable ...$content) - * @method static static rp(string|\Stringable ...$content) - * @method static static rt(string|\Stringable ...$content) - * @method static static ruby(string|\Stringable ...$content) - * @method static static s(string|\Stringable ...$content) - * @method static static samp(string|\Stringable ...$content) - * @method static static script(string|\Stringable ...$content) - * @method static static section(string|\Stringable ...$content) - * @method static static select(string|\Stringable ...$content) - * @method static static small(string|\Stringable ...$content) - * @method static static source(string|\Stringable ...$content) - * @method static static span(string|\Stringable ...$content) - * @method static static strong(string|\Stringable ...$content) - * @method static static style(string|\Stringable ...$content) - * @method static static sub(string|\Stringable ...$content) - * @method static static summary(string|\Stringable ...$content) - * @method static static sup(string|\Stringable ...$content) - * @method static static table(string|\Stringable ...$content) - * @method static static tbody(string|\Stringable ...$content) - * @method static static td(string|\Stringable ...$content) - * @method static static template(string|\Stringable ...$content) - * @method static static textarea(string|\Stringable ...$content) - * @method static static tfoot(string|\Stringable ...$content) - * @method static static th(string|\Stringable ...$content) - * @method static static thead(string|\Stringable ...$content) - * @method static static time(string|\Stringable ...$content) - * @method static static title(string|\Stringable ...$content) - * @method static static tr(string|\Stringable ...$content) - * @method static static track(string|\Stringable ...$content) - * @method static static u(string|\Stringable ...$content) - * @method static static ul(string|\Stringable ...$content) - * @method static static var(string|\Stringable ...$content) - * @method static static video(string|\Stringable ...$content) - * @method static static wbr(string|\Stringable ...$content) - * - * Deprecated in 5; last available in 4.01, X1.1: - * @method static static acronym(string|\Stringable ...$content) - * @method static static big(string|\Stringable ...$content) - * @method static static tt(string|\Stringable ...$content) - * - * Deprecated in X1.1; last available in 4.01, X1.0: - * @method static static applet(string|\Stringable ...$content) - * @method static static basefont(string|\Stringable ...$content) - * @method static static center(string|\Stringable ...$content) - * @method static static dir(string|\Stringable ...$content) - * @method static static font(string|\Stringable ...$content) - * @method static static isindex(string|\Stringable ...$content) - * @method static static noframes(string|\Stringable ...$content) - * @method static static strike(string|\Stringable ...$content) - * - * Deprecated in X1.0; last available in 4.01: - * @method static static frame(string|\Stringable ...$content) - * @method static static frameset(string|\Stringable ...$content) - * - * Deprecated in 4.01; last available in 3.2: - * @method static static plaintext(string|\Stringable ...$content) - * @method static static xmp(string|\Stringable ...$content) - * - * Deprecated in 3.2; last available in 2.0: - * @method static static nextid(string|\Stringable ...$content) - * - * No spec data in source (verify before relying on these): - * @method static static hgroup(string|\Stringable ...$content) - * @method static static rbc(string|\Stringable ...$content) - * @method static static rtc(string|\Stringable ...$content) + * @method static static area(string ...$properties) + * @method static static base(string ...$properties) + * @method static static br(string ...$properties) + * @method static static col(string ...$properties) + * @method static static embed(string ...$properties) + * @method static static hr(string ...$properties) + * @method static static img(string ...$properties) + * @method static static input(string ...$properties) + * @method static static link(string ...$properties) + * @method static static meta(string ...$properties) + * @method static static source(string ...$properties) + * @method static static track(string ...$properties) + * @method static static wbr(string ...$properties) */ class EmptyElement implements PropertyInterface { - /** - * The following attributes will be placed in this order if used in an - * element. To change the order, extend the Element class and override - * this constant. - */ - public const ORDERED = [ - "is", - "role", - "id", - "class", - "style", - "name", - "type", - "media", - "tabindex", - "accesskey", - "width", - "height", - "lang", - "srclang", - "hreflang", - "dir", - "translate", - "src", - "rel", - "href", - "target", - "itemtype", - "itemref", - "itemprop", - "title", - "http-equiv", - "charset", - "alt", - "value", - "content", - "manifest", - "contenteditable", - "spellcheck", - "start" - ]; - public static function create(string $name, string ...$properties): static { $properties = array_values($properties); diff --git a/tests/EmptyElementBaselineTest.php b/tests/EmptyElementBaselineTest.php new file mode 100644 index 0000000..47bda04 --- /dev/null +++ b/tests/EmptyElementBaselineTest.php @@ -0,0 +1,24 @@ + + html; + + $result = (string) Element::img(); + + $this->assertSame($expected, $result); + } +} From 6835a615accdadc2822d1dcaf4191489c8dde47f Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 11:19:03 -0500 Subject: [PATCH 08/27] red: Expect PHPtan to fail because H1 not in valid empty element list --- tests/EmptyElementBaselineTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/EmptyElementBaselineTest.php b/tests/EmptyElementBaselineTest.php index 47bda04..e497623 100644 --- a/tests/EmptyElementBaselineTest.php +++ b/tests/EmptyElementBaselineTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; class EmptyElementBaselineTest extends TestCase { @@ -17,7 +17,7 @@ public function has_correct_selfclosing_string(): void // phpcs:ignore html; - $result = (string) Element::img(); + $result = (string) EmptyElement::h1(); $this->assertSame($expected, $result); } From 39cdf7197166f9cf3558fe729763bd951adcecc5 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 15:38:28 -0500 Subject: [PATCH 09/27] red: Add tests/ to static analysis; fix performance test --- phpstan.neon | 1 + tests/PerformanceTest.php | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index ec91830..0b966e9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,3 +2,4 @@ parameters: level: 10 paths: - src + - tests diff --git a/tests/PerformanceTest.php b/tests/PerformanceTest.php index d2124ee..939d998 100644 --- a/tests/PerformanceTest.php +++ b/tests/PerformanceTest.php @@ -14,6 +14,9 @@ class PerformanceTest extends TestCase { + /** + * @return list [description] + */ private function head(): array { $head = []; @@ -26,6 +29,9 @@ private function head(): array return $head; } + /** + * @return list [description] + */ private function body(): array { $body = []; @@ -51,12 +57,12 @@ public function document_is_speedy(): void // phpcs:ignore $expected = file_get_contents(__DIR__ . '/performance.html'); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); $elapsed = $end - $start; $ms = $elapsed/1e+6; - $this->assertLessThan(1, $ms); + parent::assertLessThan(1, $ms); } #[Test] @@ -72,11 +78,11 @@ public function document_is_small(): void // phpcs:ignore $expected = file_get_contents(__DIR__ . '/performance.html'); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); $used = $end - $start; $kb = round($used/1024.2); - $this->assertLessThan(66, $kb); + parent::assertLessThan(66, $kb); } } From 9c1254cc14e90e61cffe975133ac94bac93536b3 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 15:39:08 -0500 Subject: [PATCH 10/27] red: Fix dynamic call of static method and static analysis issues --- tests/Components/CopyrightTest.php | 18 +++++++++--------- tests/Forms/TextAreaTest.php | 12 +++++++++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/Components/CopyrightTest.php b/tests/Components/CopyrightTest.php index 3f63d27..4ef542f 100644 --- a/tests/Components/CopyrightTest.php +++ b/tests/Components/CopyrightTest.php @@ -17,7 +17,7 @@ public function can_use_default(): void $result = (string) Copyright::create('Dr. Copyright'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -30,7 +30,7 @@ public function can_use_bare_string(): void $result = (string) Copyright::create('Dr. Copyright')->stringOnly(); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -42,7 +42,7 @@ public function can_use_bare_string(): void wrapInParagraph: false ); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -55,7 +55,7 @@ public function can_have_year_range(): void $result = (string) Copyright::create('Dr. Copyright', '2020'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -69,7 +69,7 @@ public function can_include_copyright(): void $result = (string) Copyright::create('Dr. Copyright') ->spellOutCopyright(); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -79,7 +79,7 @@ public function can_include_copyright(): void $result = (string) Copyright::create('Dr. Copyright') ->expandedCopyright(); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -93,7 +93,7 @@ public function can_modify_reservations(): void $result = (string) Copyright::create('Dr. Copyright') ->allRightsReserved(); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -103,7 +103,7 @@ public function can_modify_reservations(): void $result = (string) Copyright::create('Dr. Copyright') ->someRightsReserved(); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -113,7 +113,7 @@ public function can_modify_reservations(): void $result = (string) Copyright::create('Dr. Copyright') ->noRightsReserved(); - $this->assertSame( + parent::assertSame( $expected, $result ); diff --git a/tests/Forms/TextAreaTest.php b/tests/Forms/TextAreaTest.php index 8d11449..78d1b60 100644 --- a/tests/Forms/TextAreaTest.php +++ b/tests/Forms/TextAreaTest.php @@ -84,9 +84,12 @@ public function caller_selected_escaping_options_are_preserved(int $flags): void $html, 'TextArea must not impose its own encoding options on caller-escaped values.' ); + + $node = self::xpath($html)->query('//*[local-name()="textarea"]')->item(0); + parent::assertNotNull($node); parent::assertSame( $value, - self::xpath($html)->query('//*[local-name()="textarea"]')->item(0)->textContent + $node->textContent ); } @@ -104,10 +107,13 @@ public function supplied_content_is_not_automatically_sanitized(): void #[Test] public function carriage_returns_follow_native_html_newline_normalization(): void { - $xpath = self::xpath((string) TextArea::create('Notes', 'notes', "\r\nFirst\rSecond\r\n")); + $textarea = (string) TextArea::create('Notes', 'notes', "\r\nFirst\rSecond\r\n"); + $node = self::xpath($textarea)->query('//*[local-name()="textarea"]')->item(0); + + parent::assertNotNull($node); parent::assertSame( "\nFirst\nSecond\n", - $xpath->query('//*[local-name()="textarea"]')->item(0)->textContent, + $node->textContent, 'HTML parsing normalizes CR and CRLF to LF without losing leading or trailing line breaks.' ); } From c105e71a2973a9e8dc3ecf050d29879a9c81edc2 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 18:04:03 -0500 Subject: [PATCH 11/27] red: FieldsetTest passes static analysis --- tests/Forms/FieldsetTest.php | 78 ++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/tests/Forms/FieldsetTest.php b/tests/Forms/FieldsetTest.php index 4e6482c..bee4aac 100644 --- a/tests/Forms/FieldsetTest.php +++ b/tests/Forms/FieldsetTest.php @@ -12,29 +12,58 @@ final class FieldsetTest extends TestCase { + private static function collapsedHtml(string $html): string + { + return preg_replace('/>\s+<', $html) ?? ''; + } + #[Test] public function object_warning_is_rendered_locally_without_mutating_the_original(): void { + $html = << + Group + + + html; + + $expected = self::collapsedHtml($html); + $warning = Element::div( Element::a('Review guidance')->props('href /guidance/') )->props('id original', 'class custom-warning', 'data-example preserved'); + $before = (string) $warning; + $fieldset = Fieldset::create('Group', 'group', $warning); - $expected = '
Group' - . '
'; for ($render = 0; $render < 3; $render++) { - $html = (string) $fieldset; + $result = (string) $fieldset; parent::assertSame($before, (string) $warning); - parent::assertSame($expected, $html); - parent::assertSame(1, substr_count($html, 'id="group-warning"')); + parent::assertSame($expected, $result); + parent::assertSame(1, substr_count($result, 'id="group-warning"')); } } #[Test] public function shared_object_warning_keeps_independent_component_ids(): void { + $htmlFirst = << + First +

Check the value.

+ + html; + + $htmlSecond = << + Second +

Check the value.

+ + html; + $warning = Element::p('Check the value.')->props('id original', 'class warning'); $before = (string) $warning; $first = Fieldset::create('First', 'first', $warning); @@ -46,15 +75,14 @@ public function shared_object_warning_keeps_independent_component_ids(): void $inputHtml = (string) $input; parent::assertSame( - '
First

' - . 'Check the value.

', + self::collapsedHtml($htmlFirst), $firstHtml ); parent::assertSame( - '
Second

' - . 'Check the value.

', + self::collapsedHtml($htmlSecond), $secondHtml ); + parent::assertStringContainsString( '

Check the value.

', $inputHtml @@ -68,12 +96,19 @@ public function shared_object_warning_keeps_independent_component_ids(): void #[Test] public function string_warning_preserves_markup_in_one_paragraph(): void { + $html = << + Group +

Check the value.

+ + html; + $fieldset = Fieldset::create('Group', 'group', 'Check the value.'); - $expected = '
Group' - . '

Check the value.

'; - parent::assertSame($expected, (string) $fieldset); - parent::assertSame($expected, (string) $fieldset); + parent::assertSame( + self::collapsedHtml($html), + (string) $fieldset + ); } #[Test] @@ -93,16 +128,25 @@ public function warning_does_not_rewrite_arbitrary_child_controls(): void 'aria-describedby child-help group-warning', 'aria-invalid false' ); + $before = (string) $child; $rawChild = ''; $fieldset = Fieldset::create('Group', 'group', 'Check the group.')->elements( Element::div($child), $rawChild ); - $expected = '
Group
' . $before . '
' - . $rawChild . '

Check the group.

'; - parent::assertSame($expected, (string) $fieldset); + $html = << + Group +
{$before}
+ {$rawChild} +

Check the group.

+ + html; + + $expected = self::collapsedHtml($html); + parent::assertSame($expected, (string) $fieldset); parent::assertSame($before, (string) $child); } From 382eb284adc89db31c88699c5bf9cd7885088e56 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 18:32:19 -0500 Subject: [PATCH 12/27] red: Delete ContainerPropsTest as unnecessary --- tests/Forms/ContainerPropsTest.php | 68 ------------------------------ 1 file changed, 68 deletions(-) delete mode 100644 tests/Forms/ContainerPropsTest.php diff --git a/tests/Forms/ContainerPropsTest.php b/tests/Forms/ContainerPropsTest.php deleted file mode 100644 index 1bab6a8..0000000 --- a/tests/Forms/ContainerPropsTest.php +++ /dev/null @@ -1,68 +0,0 @@ - */ - public static function components(): iterable - { - foreach (['text', 'email', 'password', 'textarea', 'fieldset', 'dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { - yield $mode => [$mode]; - } - } - - #[Test] - #[DataProvider('components')] - public function deprecated_alias_preserves_fluent_return_and_rendered_output(string $mode): void - { - $component = match ($mode) { - 'text' => TextInput::create('Field', 'field'), - 'email' => EmailInput::create('Field', 'field'), - 'password' => Password::create('Field', 'field'), - 'textarea' => TextArea::create('Field', 'field'), - 'fieldset' => Fieldset::create('Field', 'field'), - default => Select::create('Field', 'field', ['a' => 'A'])->$mode() - }; - $properties = ['class container', 'data-example preserved']; - $modern = clone $component; - parent::assertSame($modern, $modern->containerProps(...$properties)); - $attribute = new ReflectionMethod($component, 'wrapperProps') - ->getAttributes(Deprecated::class)[0]->newInstance(); - parent::assertSame('Use containerProps() instead.', $attribute->message); - parent::assertSame('5.0', $attribute->since); - - $deprecations = []; - set_error_handler(static function (int $severity, string $message) use (&$deprecations): bool { - if ($severity !== E_USER_DEPRECATED) { - return false; - } - $deprecations[] = $message; - return true; - }); - try { - $result = $component->wrapperProps(...$properties); - } finally { - restore_error_handler(); - } - - parent::assertSame($component, $result, 'The deprecated alias must return containerProps() results.'); - parent::assertSame((string) $modern, (string) $component, 'Renaming must not change markup.'); - parent::assertCount(1, $deprecations, 'The native attribute must emit one deprecation per call.'); - parent::assertStringContainsString('Use containerProps() instead.', $deprecations[0]); - } -} From 099340bee5fc6321659f8b5f8268cc36705e2064 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 18:32:30 -0500 Subject: [PATCH 13/27] red: Fix PHPStan dynamic call --- tests/ElementExtensionBaselineTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ElementExtensionBaselineTest.php b/tests/ElementExtensionBaselineTest.php index f7e24bc..6b6b7b4 100644 --- a/tests/ElementExtensionBaselineTest.php +++ b/tests/ElementExtensionBaselineTest.php @@ -26,6 +26,6 @@ public function has_ordered_properties(): void // phpcs:ignore 'is link' ); - $this->assertSame($exptected, $result); + parent::assertSame($exptected, $result); } } From c93a968c7316866a01320c214f264bb786c1a7ca Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 18:35:14 -0500 Subject: [PATCH 14/27] red: Fix ElementBaseline and add EmptyElement check --- tests/ElementBaselineTest.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/ElementBaselineTest.php b/tests/ElementBaselineTest.php index 6bb7c19..7b81c81 100644 --- a/tests/ElementBaselineTest.php +++ b/tests/ElementBaselineTest.php @@ -7,6 +7,8 @@ use PHPUnit\Framework\TestCase; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; + class ElementBaselineTest extends TestCase { @@ -19,7 +21,7 @@ public function can_have_empty_props(): void // phpcs:ignore $result = (string) Element::a('link')->props(''); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[Test] @@ -37,18 +39,22 @@ public function has_ordered_properties(): void // phpcs:ignore 'data-testing test' ); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[Test] public function has_correct_selfclosing_string(): void // phpcs:ignore { $expected = << + html; - $result = (string) Element::tag()->omitEndTag(); + $result = (string) Element::img()->omitEndTag(); + + parent::assertSame($expected, $result); + + $result = (string) EmptyElement::img(); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } } From 09e3ca5d859edcbe8fb27d003e636e3fec6d3e2f Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 18:56:59 -0500 Subject: [PATCH 15/27] red: All pass tests/ pass PHPStan --- tests/Components/FaviconsTest.php | 14 +- tests/ControlContractTest.php | 4 +- tests/ControlGuidanceTest.php | 4 +- tests/DocumentBaselineTest.php | 37 +-- tests/Forms/SelectTest.php | 446 ++++++++++++++++-------------- 5 files changed, 262 insertions(+), 243 deletions(-) diff --git a/tests/Components/FaviconsTest.php b/tests/Components/FaviconsTest.php index 87388f7..b89f08b 100644 --- a/tests/Components/FaviconsTest.php +++ b/tests/Components/FaviconsTest.php @@ -19,7 +19,7 @@ public function can_use_default(): void $result = (string) Favicons::create(); - $this->assertSame( + parent::assertSame( $expected, $result . "\n" ); @@ -34,7 +34,7 @@ public function can_set_theme_color(): void themeColor: '#000000' ); - $this->assertSame( + parent::assertSame( $expected, $result . "\n" ); @@ -47,7 +47,7 @@ public function can_use_subfolder(): void $result = (string) Favicons::create('/subfolder'); - $this->assertSame( + parent::assertSame( $expected, $result . "\n" ); @@ -60,7 +60,7 @@ public function can_use_custom_app_name(): void $result = (string) Favicons::create()->withAppName('Override page title'); - $this->assertSame( + parent::assertSame( $expected, $result . "\n" ); @@ -73,7 +73,7 @@ public function can_use_windows_metro_settings(): void $result = (string) Favicons::create()->withMetro(useWhite: true); - $this->assertSame( + parent::assertSame( $expected, $result . "\n" ); @@ -86,7 +86,7 @@ public function can_use_windows_metro_settings(): void FaviconMetroColors::Teal ); - $this->assertSame( + parent::assertSame( $expected, $result . "\n" ); @@ -99,7 +99,7 @@ public function can_use_windows_metro_settings(): void '#ffffff' ); - $this->assertSame( + parent::assertSame( $expected, $result . "\n" ); diff --git a/tests/ControlContractTest.php b/tests/ControlContractTest.php index b36f7f0..b31332a 100644 --- a/tests/ControlContractTest.php +++ b/tests/ControlContractTest.php @@ -49,7 +49,7 @@ public function constructor_owns_value_including_empty( string $warning, array $properties ): void { - $control = match ($mode) { + $control = match ($mode) { // @phpstan-ignore match.unhandled 'text' => TextInput::create('Field', 'field', $value, $warning), 'email' => EmailInput::create('Field', 'field', $value, $warning) }; @@ -94,7 +94,7 @@ public static function labelCases(): iterable #[DataProvider('labelCases')] public function generated_label_association_overrides_caller_properties(string $mode, ?string $id): void { - $control = match ($mode) { + $control = match ($mode) { // @phpstan-ignore match.unhandled 'text' => TextInput::create('Field', 'field'), 'email' => EmailInput::create('Field', 'field'), 'password' => Password::create('Field', 'field'), diff --git a/tests/ControlGuidanceTest.php b/tests/ControlGuidanceTest.php index af1fcd2..d032558 100644 --- a/tests/ControlGuidanceTest.php +++ b/tests/ControlGuidanceTest.php @@ -143,7 +143,7 @@ private static function render( array $properties, string $id = 'field' ): string { - $control = match ($mode) { + $control = match ($mode) { // @phpstan-ignore match.unhandled 'text' => TextInput::create('Field', 'field', '', $warning), 'email' => EmailInput::create('Field', 'field', '', $warning), 'password' => Password::create('Field', 'field', $warning), @@ -210,7 +210,7 @@ private static function assertGuidance( } else { parent::assertSame( $expectedIds, - preg_split('/\s+/', trim($control->getAttribute('aria-describedby'))), + preg_split('/\s+/', trim($control->getAttribute('aria-describedby'))), // @phpstan-ignore argument.type 'aria-describedby must preserve help IDs and append the warning ID once, in order. ' . $controlContext ); diff --git a/tests/DocumentBaselineTest.php b/tests/DocumentBaselineTest.php index 9b0c59d..2f288a4 100644 --- a/tests/DocumentBaselineTest.php +++ b/tests/DocumentBaselineTest.php @@ -9,6 +9,7 @@ use Eightfold\HtmlBuilder\Document; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; use Eightfold\HtmlBuilder\Components\PageTitle; @@ -27,7 +28,7 @@ public function adding_lang_to_html_props_overrides_create(): void // phpcs:igno 'en-us' )->htmlProps('class dark-theme', 'lang fr'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -38,14 +39,15 @@ public function can_add_properties_to_html_element(): void // phpcs:ignore { $expected = << - Second | First + + Second | First html; $result = (string) Document::create( PageTitle::create(['Second', 'First']) )->htmlProps('class dark-theme'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -56,14 +58,15 @@ public function can_add_properties_to_body_element(): void // phpcs:ignore { $expected = << - Second | First + + Second | First html; $result = (string) Document::create( PageTitle::create(['Second', 'First']) )->bodyProps('style color: #000;', 'class world', 'id hello'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -81,7 +84,7 @@ public function can_use_page_title(): void // phpcs:ignore PageTitle::create(['Second', 'First']) ); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -96,14 +99,13 @@ public function is_stringable(): void // phpcs:ignore html; $result = (string) Document::create('title', 'fr', 'ascii')->head( - Element::link() - ->omitEndTag()->props('rel stylesheet', 'href style.css'), + EmptyElement::link()->props('rel stylesheet', 'href style.css'), Element::script()->props('src script.js') )->body( Element::p('paragraph content') ); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[Test] @@ -115,14 +117,13 @@ public function can_have_body(): void // phpcs:ignore html; $result = (string) Document::create('title', 'fr', 'ascii')->head( - Element::link() - ->omitEndTag()->props('rel stylesheet', 'href style.css'), + EmptyElement::link()->props('rel stylesheet', 'href style.css'), Element::script()->props('src script.js') )->body( Element::p('paragraph content') ); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[Test] @@ -130,16 +131,16 @@ public function can_have_head(): void // phpcs:ignore { $expected = << - title + + title html; $result = (string) Document::create('title', 'fr', 'ascii')->head( - Element::link() - ->omitEndTag()->props('rel stylesheet', 'href style.css'), + EmptyElement::link()->props('rel stylesheet', 'href style.css'), Element::script()->props('src script.js') ); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[Test] @@ -152,7 +153,7 @@ public function can_change_lang_and_char_set(): void // phpcs:ignore $result = (string) Document::create('title', 'fr', 'ascii'); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[Test] @@ -165,6 +166,6 @@ public function has_baseline(): void // phpcs:ignore $result = (string) Document::create('title'); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } } diff --git a/tests/Forms/SelectTest.php b/tests/Forms/SelectTest.php index 69c645e..f04244e 100644 --- a/tests/Forms/SelectTest.php +++ b/tests/Forms/SelectTest.php @@ -16,214 +16,105 @@ class SelectTest extends TestCase { - /** @return iterable */ - public static function modes(): iterable + private static function collapsedHtml(string $html): string { - foreach (['dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { - yield $mode => [$mode]; - } + return preg_replace('/>\s+<', $html) ?? ''; } - /** - * @return iterable, list}> - */ - public static function selections(): iterable + #[Test] + public function selection_is_driven_by_mode(): void { - foreach (['dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { - yield $mode . ': empty' => [$mode, [], []]; - yield $mode . ': scalar' => [$mode, 'b', ['b']]; - yield $mode . ': unmatched' => [$mode, ['missing'], []]; - yield $mode . ': numeric key' => [$mode, ['0'], ['0']]; - yield $mode . ': array' => [ - $mode, - ['b', 'a'], - in_array($mode, ['multiple', 'checkbox'], true) ? ['a', 'b'] : ['b'] - ]; - yield $mode . ': unmatched first value' => [ - $mode, - ['missing', 'b'], - in_array($mode, ['multiple', 'checkbox'], true) ? ['b'] : [] - ]; - } - } + $html = << + + + + html; + + $expected = self::collapsedHtml($html); - /** - * @param string|list $selected - * @param list $expected - */ - #[Test] - #[DataProvider('selections')] - public function selection_is_driven_by_mode_and_selected_values( - string $mode, - string|array $selected, - array $expected - ): void { $control = Select::create( 'Categories', 'categories', - ['a' => 'A', 'b' => 'B', '0' => 'Zero'], - $selected - ); - $xpath = self::controlXpath($control->$mode()->inputProps('checked checked', 'multiple multiple')); - $nodes = $xpath->query( - '//*[local-name()="option" and @selected or local-name()="input" and @checked]' - ); - parent::assertNotFalse($nodes); - $actual = []; - foreach ($nodes as $node) { - $actual[] = $node->getAttribute('value'); - } - parent::assertSame( - $expected, - $actual, - $mode . ': inputProps must not override selection; single modes use only the first supplied value.' + ['a' => 'A', 'b' => 'B', '0' => 'Zero'] ); - parent::assertSame( - $mode === 'multiple' ? 1 : 0, - $xpath->query('//*[@multiple]')->length, - $mode . ': only explicit multiple-dropdown mode may emit multiple.' - ); - } - #[Test] - #[DataProvider('modes')] - public function native_attributes_target_controls_and_preserve_generated_identity(string $mode): void - { - $control = Select::create('Categories', 'categories', ['a' => 'A', 'b' => 'B']); - $isGroup = in_array($mode, ['radio', 'checkbox'], true); - $xpath = self::controlXpath( - $control->$mode()->id('custom')->inputProps( - 'id wrong', - 'name wrong', - 'type wrong', - 'value wrong', - 'required required', - 'disabled disabled', - 'aria-describedby category-help', - ...($isGroup ? [] : ['size 4']) - )->content(Element::p('Help')->props('id category-help')) - ->containerProps('class wrapper')->labelProps('class caption') - ); - $controls = $xpath->query('//*[local-name()="input" or local-name()="select"]'); - parent::assertNotFalse($controls); - parent::assertCount($isGroup ? 2 : 1, $controls); - foreach ($controls as $index => $control) { - $id = $isGroup ? 'custom-option-' . $index : 'custom'; - parent::assertSame($id, $control->getAttribute('id'), $mode . ': generated ID must win.'); - parent::assertSame( - $mode === 'checkbox' ? 'categories[]' : 'categories', - $control->getAttribute('name'), - $mode . ': generated submitted name must win.' - ); - parent::assertTrue($control->hasAttribute('required'), $mode . ': required must reach the control.'); - parent::assertTrue($control->hasAttribute('disabled'), $mode . ': disabled must reach the control.'); - if (!$isGroup) { - parent::assertSame('4', $control->getAttribute('size'), 'Dropdown size must reach the select.'); - } - parent::assertSame('category-help', $control->getAttribute('aria-describedby')); - parent::assertFalse($control->hasAttribute('aria-invalid'), 'Help alone must not imply an invalid value.'); - parent::assertSame( - 1, - $xpath->query('//*[local-name()="label" and @for="' . $id . '"]')->length, - $mode . ': each control must retain its associated label.' - ); - if ($isGroup) { - parent::assertSame($mode, $control->getAttribute('type'), 'Generated input type must win.'); - parent::assertSame(['a', 'b'][$index], $control->getAttribute('value'), 'Option value must win.'); - } - } - parent::assertSame(0, $xpath->query('//*[@class="wrapper" and (@required or @disabled or @size or @aria-describedby)]')->length); - parent::assertSame( - 1, - $xpath->query('//*[@class="wrapper" and local-name()="' . ($isGroup ? 'fieldset' : 'div') . '"]')->length - ); - parent::assertSame( - 1, - $xpath->query('//*[@class="caption" and local-name()="' . ($isGroup ? 'legend' : 'label') . '"]')->length - ); - } + $result = (string) $control; - #[Test] - public function switching_from_multiple_to_dropdown_restores_single_selection(): void - { - $control = Select::create('Categories', 'categories', ['a' => 'A', 'b' => 'B'], ['b', 'a']); - $xpath = self::controlXpath($control->multiple()->dropdown()); - parent::assertSame(0, $xpath->query('//*[@multiple]')->length, 'dropdown() must remove multiple mode.'); - parent::assertSame(1, $xpath->query('//*[local-name()="option" and @selected]')->length); - parent::assertSame( - 'b', - $xpath->evaluate('string(//*[local-name()="option" and @selected]/@value)'), - 'Returning to dropdown mode must select only the first supplied value.' - ); - } + parent::assertSame($expected, $result); - /** @return iterable */ - public static function submittedNames(): iterable - { - foreach (['categories', 'categories[]'] as $name) { - foreach (self::modes() as [$mode]) { - yield $mode . ': ' . $name => [ - $mode, - $name, - $mode === 'checkbox' ? 'categories[]' : $name - ]; - } - } - } + $html = << + Categories +
+ + +
+
+ + +
+
+ + +
+ + html; - #[Test] - #[DataProvider('submittedNames')] - public function submitted_names_preserve_existing_conventions( - string $mode, - string $name, - string $expected - ): void { - $control = Select::create('Categories', $name, ['a' => 'A']); - $xpath = self::controlXpath($control->$mode()); - parent::assertSame( - $expected, - $xpath->evaluate('string(//*[local-name()="input" or local-name()="select"]/@name)'), - $mode . ': only checkbox groups append [], and never twice.' - ); - } + $expected = self::collapsedHtml($html); - #[Test] - #[DataProvider('modes')] - public function group_disabling_and_required_attributes_follow_native_semantics(string $mode): void - { - $control = Select::create('Categories', 'categories', ['a' => 'A', 'b' => 'B']); - $xpath = self::controlXpath( - $control->$mode()->containerProps('disabled disabled')->inputProps('required required') - ); - $isGroup = in_array($mode, ['radio', 'checkbox'], true); - parent::assertSame($isGroup ? 1 : 0, $xpath->query('//*[local-name()="fieldset" and @disabled]')->length); - parent::assertSame( - $isGroup ? 2 : 1, - $xpath->query('//*[local-name()="input" and @required or local-name()="select" and @required]')->length, - 'required is forwarded to each control, not interpreted as a group validation rule.' - ); - parent::assertSame( - 0, - $xpath->query('//*[local-name()="input" and @disabled or local-name()="select" and @disabled]')->length, - 'Group disabling belongs on the fieldset; member attributes must not be rewritten.' - ); - } + $result = (string) $control->radio(); - private static function controlXpath(Select $control): XPath - { - return new XPath(HTMLDocument::createFromString( - 'Select test' - . $control . '' - )); + parent::assertSame($expected, $result); + + $html = << + Categories +
+ + +
+
+ + +
+
+ + +
+ + html; + + $expected = self::collapsedHtml($html); + + $result = (string) $control->checkbox(); + + parent::assertSame($expected, $result); } #[Test] public function ticket_36(): void // phpcs: ignore { - $expected = <<Toppings

Choose at least one topping.

+ $html = << + Toppings +
+ + +
+
+ + +
+

Choose at least one topping.

html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( 'Toppings', 'toppings', @@ -238,10 +129,21 @@ public function ticket_36(): void // phpcs: ignore #[Test] public function can_be_multiple(): void // phpcs: ignore { - $expected = <<
  • Invalid value
+ $html = << + + +
    +
  • Invalid value
  • +
+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( name: 'select', options: [ @@ -260,10 +162,21 @@ public function can_be_multiple(): void // phpcs: ignore #[Test] public function warning_message_can_be_property_interface(): void // phpcs: ignore { - $expected = <<
  • Invalid value
+ $html = << + + +
    +
  • Invalid value
  • +
+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( label: 'Select', name: 'select', @@ -282,10 +195,19 @@ public function warning_message_can_be_property_interface(): void // phpcs: igno #[Test] public function warning_message_can_be_string(): void // phpcs: ignore { - $expected = <<

Invalid value

+ $html = << + + +

Invalid value

+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( label: 'Select', name: 'select', @@ -302,10 +224,18 @@ public function warning_message_can_be_string(): void // phpcs: ignore #[Test] public function can_add_props_to_label(): void // phpcs: ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( label: 'Select', name: 'select', @@ -317,10 +247,22 @@ public function can_add_props_to_label(): void // phpcs: ignore parent::assertSame($expected, $result); - $expected = <<Select
+ $html = << + Select +
+ + +
+
+ + +
+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( label: 'Select', name: 'select', @@ -332,10 +274,22 @@ public function can_add_props_to_label(): void // phpcs: ignore parent::assertSame($expected, $result); - $expected = <<Select
+ $html = << + Select +
+ + +
+
+ + +
+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( label: 'Select', name: 'select', @@ -351,10 +305,22 @@ public function can_add_props_to_label(): void // phpcs: ignore #[Test] public function can_be_checkboxes(): void // phpcs: ignore { - $expected = <<Select your option
+ $html = << + Select your option +
+ + +
+
+ + +
+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( label: 'Select your option', name: 'select', @@ -371,10 +337,18 @@ public function can_be_checkboxes(): void // phpcs: ignore #[Test] public function can_be_radio_buttons(): void // phpcs: ignore { - $expected = <<Select your option
+ $html = << + Select your option +
+ + +
+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( 'Select your option', 'select', @@ -390,10 +364,17 @@ public function can_be_radio_buttons(): void // phpcs: ignore #[Test] public function dropdown_mode_is_explicit(): void // phpcs: ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( 'Select your option', 'select', @@ -408,10 +389,18 @@ public function dropdown_mode_is_explicit(): void // phpcs: ignore #[Test] public function checkbox_mode_is_explicit(): void // phpcs: ignore { - $expected = <<Select your option
+ $html = << + Select your option +
+ + +
+ html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( 'Select your option', 'select', @@ -427,10 +416,17 @@ public function checkbox_mode_is_explicit(): void // phpcs: ignore #[Test] public function can_add_properties_to_wrapper(): void // phpcs: ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( 'Select your option', 'select', @@ -445,10 +441,18 @@ public function can_add_properties_to_wrapper(): void // phpcs: ignore #[Test] public function can_preselect_option(): void // phpcs:ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( 'Select your option', 'select', @@ -465,10 +469,17 @@ public function can_preselect_option(): void // phpcs:ignore #[Test] public function is_expected_base(): void // phpcs:ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Select::create( 'Select your option', 'select', @@ -483,10 +494,17 @@ public function is_expected_base(): void // phpcs:ignore #[Test] public function error_is_selected_value_always_string(): void // phpcs:ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + // Even with strict types, number-based keys become integers $result = (string) Select::create( 'Select your option', From de7794c28ca1e5207c875d38d1f1347b94a41f24 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 19:16:57 -0500 Subject: [PATCH 16/27] add: tests/ to style check --- phpcs.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/phpcs.xml b/phpcs.xml index 603b7cf..122e015 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -3,6 +3,7 @@ PHP-FIG PSR-12 standards with modifications to: control structure (empty space before closing brace). src + tests From 78c60647f56d2ce250acfdae5ceb38e4dc32c784 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:06:52 -0500 Subject: [PATCH 17/27] feat: Include bin/ in static analysis, refactor script --- bin/generate-element-docblock.php | 133 +++++++++++++++++++++++------- phpstan.neon | 1 + 2 files changed, 104 insertions(+), 30 deletions(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index 542450e..31dfba8 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -8,37 +8,96 @@ * * Authored by GitHub Copilot (session 122325ca-bce6-4353-83ce-83f97b778344), 2026-11-10. * + * Refactored by Josh Bruce, 2026-11-10. + * * Usage: php generate-element-docblock.php [elements.json] [output.php] * elements.json Path or URL to the elements reference * (default: https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json) * output.php Where to write the docblock (default: stdout) */ +use stdClass; + $source = $argv[2] ?? 'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json'; -$json = file_get_contents($source); -if ($json === false) { - fwrite(STDERR, "Could not read: {$source}\n"); - exit(1); -} +/** + * @return list + */ +function getElementEntries(string $source) : array +{ + $json = file_get_contents($source); + if ($json === false) { + fwrite(STDERR, "Could not read: {$source}\n"); + exit(1); + } -$entries = json_decode($json, true); -if (!is_array($entries)) { - fwrite(STDERR, "Invalid JSON in: {$source}\n"); - exit(1); + if (json_validate($json) === false) { + fwrite(STDERR, "Invalid JSON in: {$source}\n"); + exit(1); + } + + $entries = json_decode($json, false); + if (is_array($entries) === false) { + fwrite(STDERR, "JSON not array of element objects: {$source}\n"); + exit(1); + } + + $e = []; + foreach ($entries as $entry) { + if ( + is_object($entry) === false + or is_a($entry, stdClass::class) ===false + or property_exists($entry, 'element') === false + or is_string($entry->element) === false + or property_exists($entry, 'specs') === false + ) { + fwrite(STDERR, "JSON not array of valid objects: {$source}\n"); + exit(1); + } + $e[] = $entry; + } + + return $e; } -// The reference lists input once per type attribute (input[type="hidden"], -// etc.); the builder dispatches on tag name only, so collapse to `input`. -$specsByName = []; -foreach ($entries as $entry) { - $base = explode('[', (string) $entry['element'])[0]; - if (!array_key_exists($base, $specsByName)) { - $specsByName[$base] = $entry['specs'] ?? []; +/** + * @param list $entries + * @return array> + */ +function getSpecsByElement(array $entries): array +{ + // The reference lists input once per type attribute (input[type="hidden"], + // etc.); the builder dispatches on tag name only, so collapse to `input`. + $specsByName = []; + foreach ($entries as $entry) { + $element = $entry->element; + if (is_string($element) === false) { + $json = json_encode($entry); + fwrite(STDERR, "Element not a string: {$json}\n"); + exit(1); + } + + if (str_contains($element, '[')) { + $element = explode('[', $element) |> 'array_first'; + } + + if (array_key_exists($element, $specsByName) === false) { + if (is_array($entry->specs) === false) { + fwrite(STDERR, "'specs' not array: {$element}\n"); + exit(1); + } + $specsByName[$element] = array_filter($entry->specs, 'is_string') + |> 'array_values'; + } } + return $specsByName; } -$variantCount = count($entries) - count($specsByName); +$entries = getElementEntries($source); + +$specsByElement = getSpecsByElement($entries); + +$variantCount = count($entries) - count($specsByElement); // Group by era: current (in the newest spec) first, then deprecated groups // ordered by how recently they were dropped, with an unspecified tail group @@ -47,19 +106,19 @@ $current = end($eraOrder); $groups = []; // key: last-appearance index in $eraOrder, or -1 for unspecified -foreach ($specsByName as $name => $specs) { +foreach ($specsByElement as $element => $specs) { if ($specs === []) { - $groups[-1][] = $name; + $groups[-1][] = $element; continue; } $last = -1; foreach ($specs as $spec) { - $index = array_search((string) $spec, $eraOrder, true); + $index = array_search($spec, $eraOrder, true); if ($index !== false && $index > $last) { $last = $index; } } - $groups[$last][] = $name; + $groups[$last][] = $element; } krsort($groups); // newest last-appearance first; -1 (unspecified) lands last @@ -71,7 +130,7 @@ ' * elements.json (retrieved %s): %d entries, %d unique elements after', date('Y-m-d'), count($entries), - count($specsByName), + count($specsByElement), ); $lines[] = sprintf( ' * collapsing the %d input[type=...] variants into input. Sectioned by', @@ -84,18 +143,21 @@ $lines[] = ' * https://html.spec.whatwg.org'; $lines[] = ' *'; -$first = true; -foreach ($groups as $lastIndex => $names) { - sort($names); - if (!$first) { +$firstRun = true; +foreach ($groups as $lastIndex => $elements) { + sort($elements); + if ($firstRun === false) { $lines[] = ' *'; + } - $first = false; + $firstRun = false; if ($lastIndex === -1) { $lines[] = ' * No spec data in source (verify before relying on these):'; + } elseif ($eraOrder[$lastIndex] === $current) { $lines[] = sprintf(' * Available in %s (current):', $current); + } else { // The XHTML 1.x line runs parallel to HTML 4.01; an element dropped // after X1.0/X1.1 was typically still available in 4.01, so list both. @@ -103,14 +165,25 @@ if (str_starts_with($eraOrder[$lastIndex], 'X1')) { array_unshift($lastAvailable, '4.01'); } + + $lastAvailableIndex = $lastIndex + 1; + if (isset($eraOrder[$lastAvailableIndex]) === false) { + fwrite(STDERR, "Could not get last spec available for last index: {$lastIndex}\n"); + exit(1); + } + $lines[] = sprintf( ' * Deprecated in %s; last available in %s:', - $eraOrder[$lastIndex + 1], + $eraOrder[$lastAvailableIndex], implode(', ', $lastAvailable), ); } - foreach ($names as $name) { - $lines[] = sprintf(' * @method static static %s(string|\Stringable ...$content)', $name); + foreach ($elements as $element) { + $lines[] = sprintf( + ' * @method static static %s(string|\Stringable ...$content)', + $element + ); + } } $lines[] = ' */'; diff --git a/phpstan.neon b/phpstan.neon index 0b966e9..4be41e3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,3 +3,4 @@ parameters: paths: - src - tests + - bin From 9ae0db017d2427251f06ae75bd046d6c80ee380e Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:09:16 -0500 Subject: [PATCH 18/27] feat: Add bin/, fix errors, fix tests --- phpcs.xml | 1 + tests/DocumentBaselineTest.php | 9 +++------ tests/EmptyElementBaselineTest.php | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/phpcs.xml b/phpcs.xml index 122e015..a5c0ff7 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -4,6 +4,7 @@ src tests + bin diff --git a/tests/DocumentBaselineTest.php b/tests/DocumentBaselineTest.php index 2f288a4..39aa4f0 100644 --- a/tests/DocumentBaselineTest.php +++ b/tests/DocumentBaselineTest.php @@ -39,8 +39,7 @@ public function can_add_properties_to_html_element(): void // phpcs:ignore { $expected = << - - Second | First + Second | First html; $result = (string) Document::create( @@ -58,8 +57,7 @@ public function can_add_properties_to_body_element(): void // phpcs:ignore { $expected = << - - Second | First + Second | First html; $result = (string) Document::create( @@ -131,8 +129,7 @@ public function can_have_head(): void // phpcs:ignore { $expected = << - - title + title html; $result = (string) Document::create('title', 'fr', 'ascii')->head( diff --git a/tests/EmptyElementBaselineTest.php b/tests/EmptyElementBaselineTest.php index e497623..2e29563 100644 --- a/tests/EmptyElementBaselineTest.php +++ b/tests/EmptyElementBaselineTest.php @@ -17,8 +17,8 @@ public function has_correct_selfclosing_string(): void // phpcs:ignore html; - $result = (string) EmptyElement::h1(); + $result = (string) EmptyElement::img(); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } } From d7a380dfad1357d3b522ddcdc6e56786c0db73a8 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:12:37 -0500 Subject: [PATCH 19/27] fix: Support PHP 8.4 --- bin/generate-element-docblock.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index 31dfba8..7ab9b59 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -78,7 +78,8 @@ function getSpecsByElement(array $entries): array } if (str_contains($element, '[')) { - $element = explode('[', $element) |> 'array_first'; + // TODO: convert to pipe when PHP 8.5 required |> 'array_first'; + $element = array_first(explode('[', $element)) } if (array_key_exists($element, $specsByName) === false) { @@ -86,8 +87,10 @@ function getSpecsByElement(array $entries): array fwrite(STDERR, "'specs' not array: {$element}\n"); exit(1); } - $specsByName[$element] = array_filter($entry->specs, 'is_string') - |> 'array_values'; + // TODO: convert to pipe when PHP 8.5 required |> 'array_values'; + $specsByName[$element] = array_values( + array_filter($entry->specs, 'is_string') + ); } } return $specsByName; From c5eb7a08b387b0c101b292420daf96e3fb84c21c Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:14:41 -0500 Subject: [PATCH 20/27] fix: parse error --- bin/generate-element-docblock.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index 7ab9b59..c7e17d1 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -79,7 +79,7 @@ function getSpecsByElement(array $entries): array if (str_contains($element, '[')) { // TODO: convert to pipe when PHP 8.5 required |> 'array_first'; - $element = array_first(explode('[', $element)) + $element = array_first(explode('[', $element)); } if (array_key_exists($element, $specsByName) === false) { From d00584609330cad7ef2c230ca909e3cd2e302e30 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:18:37 -0500 Subject: [PATCH 21/27] run: PHP 8.4 fixes --- bin/generate-element-docblock.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index c7e17d1..7b927cf 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -71,17 +71,18 @@ function getSpecsByElement(array $entries): array $specsByName = []; foreach ($entries as $entry) { $element = $entry->element; + if (is_string($element) and str_contains($element, '[')) { + // TODO: convert to pipe when PHP 8.5 required + // $element = explode('[', $element) |> 'array_first'; + $element = array_unshift(explode('[', $element)); + } + if (is_string($element) === false) { $json = json_encode($entry); fwrite(STDERR, "Element not a string: {$json}\n"); exit(1); } - if (str_contains($element, '[')) { - // TODO: convert to pipe when PHP 8.5 required |> 'array_first'; - $element = array_first(explode('[', $element)); - } - if (array_key_exists($element, $specsByName) === false) { if (is_array($entry->specs) === false) { fwrite(STDERR, "'specs' not array: {$element}\n"); From 9acd8af114694dce1840a2f9c650b38486594b03 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:19:58 -0500 Subject: [PATCH 22/27] fix: PHP 8.4 --- bin/generate-element-docblock.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index 7b927cf..a1ea311 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -74,7 +74,8 @@ function getSpecsByElement(array $entries): array if (is_string($element) and str_contains($element, '[')) { // TODO: convert to pipe when PHP 8.5 required // $element = explode('[', $element) |> 'array_first'; - $element = array_unshift(explode('[', $element)); + $element = explode('[', $element); + $element = $element[0]; } if (is_string($element) === false) { From 10640bb73c2f85a212114877d382ed6e4b3ced80 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:23:30 -0500 Subject: [PATCH 23/27] update: Workflow dependencies --- .github/workflows/php84.yml | 4 ++-- .github/workflows/php85.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/php84.yml b/.github/workflows/php84.yml index 3a8169d..a7e8b7b 100644 --- a/.github/workflows/php84.yml +++ b/.github/workflows/php84.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.2 - name: Setup PHP Action - uses: shivammathur/setup-php@2.33.0 + uses: shivammathur/setup-php@2.37.0 with: php-version: '8.4' diff --git a/.github/workflows/php85.yml b/.github/workflows/php85.yml index 5704d92..cbcb07c 100644 --- a/.github/workflows/php85.yml +++ b/.github/workflows/php85.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.2 - name: Setup PHP Action - uses: shivammathur/setup-php@2.33.0 + uses: shivammathur/setup-php@2.37.0 with: php-version: '8.5' From 2f5d1235e15c5f8dae4e0b083977c1782b9a83fc Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:31:17 -0500 Subject: [PATCH 24/27] docs: Update generator docblock and composer to use PHPCS directly --- bin/generate-element-docblock.php | 5 +++-- composer.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index a1ea311..be758e9 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -10,10 +10,11 @@ * * Refactored by Josh Bruce, 2026-11-10. * - * Usage: php generate-element-docblock.php [elements.json] [output.php] + * Usage: php generate-element-docblock.php [output.php] [elements.json] + * output.php Where to write the docblock (default: stdout) + * * elements.json Path or URL to the elements reference * (default: https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json) - * output.php Where to write the docblock (default: stdout) */ use stdClass; diff --git a/composer.json b/composer.json index c6a498a..2247545 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "@check:analysis", "@check:test" ], - "check:style": "./vendor/bin/phpcs --standard=phpcs.xml -p ./src", + "check:style": "./vendor/bin/phpcs --standard=phpcs.xml", "check:analysis": "./vendor/bin/phpstan analyze", "check:test": "./vendor/bin/phpunit --display-all-issues", "build:docblocks": "php ./bin/generate-element-docblock.php elements-generated.txt && php ./bin/generate-empty-element-docblock.php empty-elements-generated.txt" From 7837424e782b530f01a20312d438a2c5cc554dcc Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 20:38:10 -0500 Subject: [PATCH 25/27] red: Fix most style errors --- bin/generate-element-docblock.php | 8 ++++---- phpcs.xml | 6 ++++++ tests/ControlContractTest.php | 6 ++++-- tests/ControlGuidanceTest.php | 12 +++++++++--- tests/DocumentBaselineTest.php | 26 +++++++++++++------------- tests/ElementBaselineTest.php | 12 ++++++------ tests/ElementExtensionBaselineTest.php | 14 +++++++------- tests/PerformanceTest.php | 4 ++-- 8 files changed, 51 insertions(+), 37 deletions(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index be758e9..ee6463d 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -19,12 +19,10 @@ use stdClass; -$source = $argv[2] ?? 'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json'; - /** * @return list */ -function getElementEntries(string $source) : array +function getElementEntries(string $source): array { $json = file_get_contents($source); if ($json === false) { @@ -47,7 +45,7 @@ function getElementEntries(string $source) : array foreach ($entries as $entry) { if ( is_object($entry) === false - or is_a($entry, stdClass::class) ===false + or is_a($entry, stdClass::class) === false or property_exists($entry, 'element') === false or is_string($entry->element) === false or property_exists($entry, 'specs') === false @@ -99,6 +97,8 @@ function getSpecsByElement(array $entries): array return $specsByName; } +$source = $argv[2] ?? 'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json'; + $entries = getElementEntries($source); $specsByElement = getSpecsByElement($entries); diff --git a/phpcs.xml b/phpcs.xml index a5c0ff7..f3bd014 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -15,6 +15,12 @@ + + + + */tests/* + + diff --git a/tests/ControlContractTest.php b/tests/ControlContractTest.php index b31332a..82f3f90 100644 --- a/tests/ControlContractTest.php +++ b/tests/ControlContractTest.php @@ -27,12 +27,14 @@ public static function valueCases(): iterable foreach (['text', 'email'] as $mode) { foreach (['empty' => '', 'nonempty' => 'current@example.com', 'zero' => '0'] as $case => $value) { foreach (['no warning' => '', 'warning' => 'Check the value.'] as $guidance => $warning) { - foreach ([ + foreach ( + [ 'single' => ['value stale@example.com'], 'repeated' => ['value first@example.com', 'value second@example.com', 'value '], 'mixed case' => ['VALUE stale@example.com', 'Value another@example.com'], 'bare' => ['value', 'value stale@example.com'] - ] as $declaration => $properties) { + ] as $declaration => $properties + ) { yield "$mode: $case, $guidance, $declaration" => [$mode, $value, $warning, $properties]; } } diff --git a/tests/ControlGuidanceTest.php b/tests/ControlGuidanceTest.php index d032558..9a71732 100644 --- a/tests/ControlGuidanceTest.php +++ b/tests/ControlGuidanceTest.php @@ -150,13 +150,19 @@ private static function render( 'textarea' => TextArea::create('Field', 'field', '', $warning), 'dropdown' => Select::create('Field', 'field', ['a' => 'A', 'b' => 'B'], [], $warning), 'multiple' => Select::create( - 'Field', 'field', ['a' => 'A', 'b' => 'B'], [], $warning + 'Field', + 'field', ['a' => 'A', 'b' => 'B'], [], $warning )->multiple(), 'radio' => Select::create( - 'Field', 'field', ['a' => 'A', 'b' => 'B'], [], $warning + 'Field', + 'field', ['a' => 'A', 'b' => 'B'], [], $warning )->radio(), 'checkbox' => Select::create( - 'Field', 'field', ['a' => 'A', 'b' => 'B'], [], $warning + 'Field', + 'field', + ['a' => 'A', 'b' => 'B'], + [], + $warning )->checkbox() }; diff --git a/tests/DocumentBaselineTest.php b/tests/DocumentBaselineTest.php index 39aa4f0..62cd66f 100644 --- a/tests/DocumentBaselineTest.php +++ b/tests/DocumentBaselineTest.php @@ -97,11 +97,11 @@ public function is_stringable(): void // phpcs:ignore html; $result = (string) Document::create('title', 'fr', 'ascii')->head( - EmptyElement::link()->props('rel stylesheet', 'href style.css'), - Element::script()->props('src script.js') - )->body( - Element::p('paragraph content') - ); + EmptyElement::link()->props('rel stylesheet', 'href style.css'), + Element::script()->props('src script.js') + )->body( + Element::p('paragraph content') + ); parent::assertSame($expected, $result); } @@ -115,11 +115,11 @@ public function can_have_body(): void // phpcs:ignore html; $result = (string) Document::create('title', 'fr', 'ascii')->head( - EmptyElement::link()->props('rel stylesheet', 'href style.css'), - Element::script()->props('src script.js') - )->body( - Element::p('paragraph content') - ); + EmptyElement::link()->props('rel stylesheet', 'href style.css'), + Element::script()->props('src script.js') + )->body( + Element::p('paragraph content') + ); parent::assertSame($expected, $result); } @@ -133,9 +133,9 @@ public function can_have_head(): void // phpcs:ignore html; $result = (string) Document::create('title', 'fr', 'ascii')->head( - EmptyElement::link()->props('rel stylesheet', 'href style.css'), - Element::script()->props('src script.js') - ); + EmptyElement::link()->props('rel stylesheet', 'href style.css'), + Element::script()->props('src script.js') + ); parent::assertSame($expected, $result); } diff --git a/tests/ElementBaselineTest.php b/tests/ElementBaselineTest.php index 7b81c81..afa0496 100644 --- a/tests/ElementBaselineTest.php +++ b/tests/ElementBaselineTest.php @@ -32,12 +32,12 @@ public function has_ordered_properties(): void // phpcs:ignore html; $result = (string) Element::a('link')->props( - 'required required', - 'href https://8fold.pro', - 'class some-style', - 'id unique', - 'data-testing test' - ); + 'required required', + 'href https://8fold.pro', + 'class some-style', + 'id unique', + 'data-testing test' + ); parent::assertSame($expected, $result); } diff --git a/tests/ElementExtensionBaselineTest.php b/tests/ElementExtensionBaselineTest.php index 6b6b7b4..c55bd88 100644 --- a/tests/ElementExtensionBaselineTest.php +++ b/tests/ElementExtensionBaselineTest.php @@ -18,13 +18,13 @@ public function has_ordered_properties(): void // phpcs:ignore html; $result = (string) ElementExtension::a('link')->props( - 'required required', - 'href https://8fold.pro', - 'class some-style', - 'id unique', - 'data-testing test', - 'is link' - ); + 'required required', + 'href https://8fold.pro', + 'class some-style', + 'id unique', + 'data-testing test', + 'is link' + ); parent::assertSame($exptected, $result); } diff --git a/tests/PerformanceTest.php b/tests/PerformanceTest.php index 939d998..d4c7779 100644 --- a/tests/PerformanceTest.php +++ b/tests/PerformanceTest.php @@ -60,7 +60,7 @@ public function document_is_speedy(): void // phpcs:ignore parent::assertSame($expected, $result); $elapsed = $end - $start; - $ms = $elapsed/1e+6; + $ms = $elapsed / 1e+6; parent::assertLessThan(1, $ms); } @@ -81,7 +81,7 @@ public function document_is_small(): void // phpcs:ignore parent::assertSame($expected, $result); $used = $end - $start; - $kb = round($used/1024.2); + $kb = round($used / 1024.2); parent::assertLessThan(66, $kb); } From 45513f29c8f8c2fb1e0993c587a046bf76b80b63 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 21:48:15 -0500 Subject: [PATCH 26/27] red: Style failures --- bin/generate-element-docblock.php | 186 +++++++++++++------------ tests/ControlContractTest.php | 42 ++++-- tests/ControlGuidanceTest.php | 63 +++++++-- tests/DocumentBaselineTest.php | 48 +++++-- tests/ElementBaselineTest.php | 10 +- tests/ElementExtensionBaselineTest.php | 6 +- tests/Extensions/ElementExtension.php | 2 +- tests/Forms/EmailInputTest.php | 50 +++++-- tests/Forms/PasswordTest.php | 53 +++++-- tests/Forms/SelectTest.php | 4 + tests/Forms/TextAreaTest.php | 74 +++++++--- tests/Forms/TextInputTest.php | 39 +++++- 12 files changed, 399 insertions(+), 178 deletions(-) diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index ee6463d..f246f98 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -14,7 +14,7 @@ * output.php Where to write the docblock (default: stdout) * * elements.json Path or URL to the elements reference - * (default: https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json) + * (default: https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json) */ use stdClass; @@ -97,114 +97,120 @@ function getSpecsByElement(array $entries): array return $specsByName; } -$source = $argv[2] ?? 'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json'; +function main(): void +{ + $source = $argv[2] ?? + 'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json'; -$entries = getElementEntries($source); + $entries = getElementEntries($source); -$specsByElement = getSpecsByElement($entries); + $specsByElement = getSpecsByElement($entries); -$variantCount = count($entries) - count($specsByElement); + $variantCount = count($entries) - count($specsByElement); -// Group by era: current (in the newest spec) first, then deprecated groups -// ordered by how recently they were dropped, with an unspecified tail group -// for entries the source gives no spec data. -$eraOrder = ['2.0', '3.2', '4.01', 'X1.0', 'X1.1', '5', '5.1', '5.2']; -$current = end($eraOrder); + // Group by era: current (in the newest spec) first, then deprecated groups + // ordered by how recently they were dropped, with an unspecified tail group + // for entries the source gives no spec data. + $eraOrder = ['2.0', '3.2', '4.01', 'X1.0', 'X1.1', '5', '5.1', '5.2']; + $current = end($eraOrder); -$groups = []; // key: last-appearance index in $eraOrder, or -1 for unspecified -foreach ($specsByElement as $element => $specs) { - if ($specs === []) { - $groups[-1][] = $element; - continue; - } - $last = -1; - foreach ($specs as $spec) { - $index = array_search($spec, $eraOrder, true); - if ($index !== false && $index > $last) { - $last = $index; + $groups = []; // key: last-appearance index in $eraOrder, or -1 for unspecified + foreach ($specsByElement as $element => $specs) { + if ($specs === []) { + $groups[-1][] = $element; + continue; + } + $last = -1; + foreach ($specs as $spec) { + $index = array_search($spec, $eraOrder, true); + if ($index !== false && $index > $last) { + $last = $index; + } } + $groups[$last][] = $element; } - $groups[$last][] = $element; -} -krsort($groups); // newest last-appearance first; -1 (unspecified) lands last - -$lines = []; -$lines[] = '/**'; -$lines[] = ' * Magic static element factories. Generated from w3c/elements-of-html'; -$lines[] = sprintf( - ' * elements.json (retrieved %s): %d entries, %d unique elements after', - date('Y-m-d'), - count($entries), - count($specsByElement), -); -$lines[] = sprintf( - ' * collapsing the %d input[type=...] variants into input. Sectioned by', - $variantCount, -); -$lines[] = ' * era: current elements first, then deprecated groups by when they were'; -$lines[] = ' * dropped, each group alphabetical.'; -$lines[] = ' *'; -$lines[] = ' * Element semantics per the WHATWG HTML living standard:'; -$lines[] = ' * https://html.spec.whatwg.org'; -$lines[] = ' *'; - -$firstRun = true; -foreach ($groups as $lastIndex => $elements) { - sort($elements); - if ($firstRun === false) { - $lines[] = ' *'; + krsort($groups); // newest last-appearance first; -1 (unspecified) lands last + + $lines = []; + $lines[] = '/**'; + $lines[] = ' * Magic static element factories. Generated from w3c/elements-of-html'; + $lines[] = sprintf( + ' * elements.json (retrieved %s): %d entries, %d unique elements after', + date('Y-m-d'), + count($entries), + count($specsByElement), + ); + $lines[] = sprintf( + ' * collapsing the %d input[type=...] variants into input. Sectioned by', + $variantCount, + ); + $lines[] = ' * era: current elements first, then deprecated groups by when they were'; + $lines[] = ' * dropped, each group alphabetical.'; + $lines[] = ' *'; + $lines[] = ' * Element semantics per the WHATWG HTML living standard:'; + $lines[] = ' * https://html.spec.whatwg.org'; + $lines[] = ' *'; + + $firstRun = true; + foreach ($groups as $lastIndex => $elements) { + sort($elements); + if ($firstRun === false) { + $lines[] = ' *'; - } - $firstRun = false; + } + $firstRun = false; - if ($lastIndex === -1) { - $lines[] = ' * No spec data in source (verify before relying on these):'; + if ($lastIndex === -1) { + $lines[] = ' * No spec data in source (verify before relying on these):'; - } elseif ($eraOrder[$lastIndex] === $current) { - $lines[] = sprintf(' * Available in %s (current):', $current); + } elseif ($eraOrder[$lastIndex] === $current) { + $lines[] = sprintf(' * Available in %s (current):', $current); - } else { - // The XHTML 1.x line runs parallel to HTML 4.01; an element dropped - // after X1.0/X1.1 was typically still available in 4.01, so list both. - $lastAvailable = [$eraOrder[$lastIndex]]; - if (str_starts_with($eraOrder[$lastIndex], 'X1')) { - array_unshift($lastAvailable, '4.01'); - } + } else { + // The XHTML 1.x line runs parallel to HTML 4.01; an element dropped + // after X1.0/X1.1 was typically still available in 4.01, so list both. + $lastAvailable = [$eraOrder[$lastIndex]]; + if (str_starts_with($eraOrder[$lastIndex], 'X1')) { + array_unshift($lastAvailable, '4.01'); + } - $lastAvailableIndex = $lastIndex + 1; - if (isset($eraOrder[$lastAvailableIndex]) === false) { - fwrite(STDERR, "Could not get last spec available for last index: {$lastIndex}\n"); - exit(1); + $lastAvailableIndex = $lastIndex + 1; + if (isset($eraOrder[$lastAvailableIndex]) === false) { + fwrite(STDERR, "Could not get last spec available for last index: {$lastIndex}\n"); + exit(1); + } + + $lines[] = sprintf( + ' * Deprecated in %s; last available in %s:', + $eraOrder[$lastAvailableIndex], + implode(', ', $lastAvailable), + ); } + foreach ($elements as $element) { + $lines[] = sprintf( + ' * @method static static %s(string|\Stringable ...$content)', + $element + ); - $lines[] = sprintf( - ' * Deprecated in %s; last available in %s:', - $eraOrder[$lastAvailableIndex], - implode(', ', $lastAvailable), - ); + } } - foreach ($elements as $element) { - $lines[] = sprintf( - ' * @method static static %s(string|\Stringable ...$content)', - $element - ); + $lines[] = ' */'; - } -} -$lines[] = ' */'; + $docblock = implode("\n", $lines) . "\n"; -$docblock = implode("\n", $lines) . "\n"; + $target = $argv[1] ?? null; + if ($target === null) { + echo $docblock; + exit(0); + } -$target = $argv[1] ?? null; -if ($target === null) { - echo $docblock; - exit(0); -} + if (file_put_contents(__DIR__ . '/../docblocks/' . $target, $docblock) === false) { + fwrite(STDERR, "Could not write: {$target}\n"); + exit(1); + } -if (file_put_contents(__DIR__ . '/../docblocks/' . $target, $docblock) === false) { - fwrite(STDERR, "Could not write: {$target}\n"); - exit(1); + fwrite(STDOUT, sprintf("Wrote %d lines to %s\n", count($lines), $target)); } -fwrite(STDOUT, sprintf("Wrote %d lines to %s\n", count($lines), $target)); +main(); diff --git a/tests/ControlContractTest.php b/tests/ControlContractTest.php index 82f3f90..07f89b5 100644 --- a/tests/ControlContractTest.php +++ b/tests/ControlContractTest.php @@ -24,18 +24,34 @@ final class ControlContractTest extends TestCase /** @return iterable}> */ public static function valueCases(): iterable { - foreach (['text', 'email'] as $mode) { - foreach (['empty' => '', 'nonempty' => 'current@example.com', 'zero' => '0'] as $case => $value) { - foreach (['no warning' => '', 'warning' => 'Check the value.'] as $guidance => $warning) { - foreach ( - [ + $modes = ['text', 'email']; + foreach ($modes as $mode) { + $cases = [ + 'empty' => '', + 'nonempty' => 'current@example.com', + 'zero' => '0' + ]; + foreach ($cases as $case => $value) { + $guidances = [ + 'no warning' => '', + 'warning' => 'Check the value.' + ]; + foreach ($guidances as $guidance => $warning) { + $declarations = [ 'single' => ['value stale@example.com'], - 'repeated' => ['value first@example.com', 'value second@example.com', 'value '], - 'mixed case' => ['VALUE stale@example.com', 'Value another@example.com'], + 'repeated' => [ + 'value first@example.com', + 'value second@example.com', 'value ' + ], + 'mixed case' => [ + 'VALUE stale@example.com', + 'Value another@example.com' + ], 'bare' => ['value', 'value stale@example.com'] - ] as $declaration => $properties - ) { - yield "$mode: $case, $guidance, $declaration" => [$mode, $value, $warning, $properties]; + ]; + foreach ($declarations as $declaration => $properties) { + yield "$mode: $case, $guidance, $declaration" => + [$mode, $value, $warning, $properties]; } } } @@ -94,8 +110,10 @@ public static function labelCases(): iterable #[Test] #[DataProvider('labelCases')] - public function generated_label_association_overrides_caller_properties(string $mode, ?string $id): void - { + public function generated_label_association_overrides_caller_properties( + string $mode, + ?string $id + ): void { $control = match ($mode) { // @phpstan-ignore match.unhandled 'text' => TextInput::create('Field', 'field'), 'email' => EmailInput::create('Field', 'field'), diff --git a/tests/ControlGuidanceTest.php b/tests/ControlGuidanceTest.php index 9a71732..18eef3f 100644 --- a/tests/ControlGuidanceTest.php +++ b/tests/ControlGuidanceTest.php @@ -24,7 +24,17 @@ final class ControlGuidanceTest extends TestCase /** @return iterable */ public static function controls(): iterable { - foreach (['text', 'email', 'password', 'textarea', 'dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { + $controls = [ + 'text', + 'email', + 'password', + 'textarea', + 'dropdown', + 'multiple', + 'radio', + 'checkbox' + ]; + foreach ($controls as $mode) { yield $mode => [$mode]; } } @@ -196,7 +206,11 @@ private static function assertGuidance( $controlIds = []; foreach ($controls as $index => $control) { - parent::assertInstanceOf(DomElement::class, $control, 'Expected a DOM element for ' . $context); + parent::assertInstanceOf( + DomElement::class, + $control, + 'Expected a DOM element for ' . $context + ); $controlContext = sprintf( 'Option/control %d (id="%s") in %s', $index + 1, @@ -211,36 +225,49 @@ private static function assertGuidance( if ($expectedIds === []) { parent::assertFalse( $control->hasAttribute('aria-describedby'), - 'Without help references or a warning, aria-describedby must be absent. ' . $controlContext + 'Without help or a warning, aria-describedby must be absent. ' + . $controlContext ); } else { parent::assertSame( $expectedIds, - preg_split('/\s+/', trim($control->getAttribute('aria-describedby'))), // @phpstan-ignore argument.type - 'aria-describedby must preserve help IDs and append the warning ID once, in order. ' - . $controlContext + preg_split( + '/\s+/', + trim($control->getAttribute('aria-describedby')) + ), // @phpstan-ignore argument.type + 'aria-describedby must preserve help IDs and append the warning ID once ' + . ', in order. ' . $controlContext ); } if ($hasWarning) { parent::assertSame( 'true', $control->getAttribute('aria-invalid'), - 'A warning must set aria-invalid="true", overriding any supplied valid state. ' . $controlContext + 'A warning must set aria-invalid="true", overriding any ' + . 'supplied valid state. ' . $controlContext ); } else { parent::assertFalse( $control->hasAttribute('aria-invalid'), - 'Without a warning or explicit invalid state, aria-invalid must be absent. ' . $controlContext + 'Without a warning or explicit invalid state, ' + . 'aria-invalid must be absent. ' . $controlContext ); } $controlId = $control->getAttribute('id'); - parent::assertNotSame('', $controlId, 'Each control needs a nonempty ID. ' . $controlContext); + parent::assertNotSame( + '', + $controlId, + 'Each control needs a nonempty ID. ' . $controlContext + ); $controlIds[] = $controlId; $labels = $xpath->query( '//*[local-name()="label" and @for="' . $controlId . '"]' ); - parent::assertNotFalse($labels, 'Could not query associated labels. ' . $controlContext); + parent::assertNotFalse( + $labels, + 'Could not query associated labels. ' . $controlContext + ); parent::assertCount( 1, $labels, @@ -255,19 +282,27 @@ private static function assertGuidance( foreach ($expectedIds as $reference) { $targets = $xpath->query('//*[@id="' . $reference . '"]'); - parent::assertNotFalse($targets, 'Could not query guidance target "' . $reference . '" for ' . $context); + parent::assertNotFalse( + $targets, + 'Could not query guidance target "' . $reference . '" for ' . $context + ); parent::assertCount( 1, $targets, - 'aria-describedby reference "' . $reference . '" must resolve to exactly one element for ' . $context + 'aria-describedby reference "' . $reference + . '" must resolve to exactly one element for ' . $context ); } $warnings = $xpath->query('//*[@id="' . $id . '-warning"]'); - parent::assertNotFalse($warnings, 'Could not query warning target "' . $id . '-warning" for ' . $context); + parent::assertNotFalse( + $warnings, + 'Could not query warning target "' . $id . '-warning" for ' . $context + ); parent::assertCount( $hasWarning ? 1 : 0, $warnings, - 'Expected ' . ($hasWarning ? 'exactly one' : 'no') . ' warning with id="' . $id . '-warning" for ' . $context + 'Expected ' . ($hasWarning ? 'exactly one' : 'no') . ' warning with id="' + . $id . '-warning" for ' . $context ); } diff --git a/tests/DocumentBaselineTest.php b/tests/DocumentBaselineTest.php index 62cd66f..5eaf333 100644 --- a/tests/DocumentBaselineTest.php +++ b/tests/DocumentBaselineTest.php @@ -18,10 +18,12 @@ class DocumentBaselineTest extends TestCase #[Test] public function adding_lang_to_html_props_overrides_create(): void // phpcs:ignore { + // phpcs:disable $expected = << Second | First html; + // phpcs:enable $result = (string) Document::create( PageTitle::create(['Second', 'First']), @@ -37,10 +39,12 @@ public function adding_lang_to_html_props_overrides_create(): void // phpcs:igno #[Test] public function can_add_properties_to_html_element(): void // phpcs:ignore { + // phpcs:disable $expected = << Second | First html; + // phpcs:enable $result = (string) Document::create( PageTitle::create(['Second', 'First']) @@ -55,10 +59,12 @@ public function can_add_properties_to_html_element(): void // phpcs:ignore #[Test] public function can_add_properties_to_body_element(): void // phpcs:ignore { + // phpcs:disable $expected = << Second | First html; + // phpcs:enable $result = (string) Document::create( PageTitle::create(['Second', 'First']) @@ -73,10 +79,12 @@ public function can_add_properties_to_body_element(): void // phpcs:ignore #[Test] public function can_use_page_title(): void // phpcs:ignore { + // phpcs:disable $expected = << Second | First html; + // phpcs:enable $result = (string) Document::create( PageTitle::create(['Second', 'First']) @@ -91,10 +99,12 @@ public function can_use_page_title(): void // phpcs:ignore #[Test] public function is_stringable(): void // phpcs:ignore { + // phpcs:disable $expected = << - title

paragraph content

- html; + + title

paragraph content

+ html; + // phpcs:disable $result = (string) Document::create('title', 'fr', 'ascii')->head( EmptyElement::link()->props('rel stylesheet', 'href style.css'), @@ -109,10 +119,12 @@ public function is_stringable(): void // phpcs:ignore #[Test] public function can_have_body(): void // phpcs:ignore { + // phpcs:disable $expected = << - title

paragraph content

- html; + + title

paragraph content

+ html; + // phpcs:enable $result = (string) Document::create('title', 'fr', 'ascii')->head( EmptyElement::link()->props('rel stylesheet', 'href style.css'), @@ -127,10 +139,12 @@ public function can_have_body(): void // phpcs:ignore #[Test] public function can_have_head(): void // phpcs:ignore { + // phpcs:disable $expected = << - title - html; + + title + html; + // phpcs:enable $result = (string) Document::create('title', 'fr', 'ascii')->head( EmptyElement::link()->props('rel stylesheet', 'href style.css'), @@ -143,10 +157,12 @@ public function can_have_head(): void // phpcs:ignore #[Test] public function can_change_lang_and_char_set(): void // phpcs:ignore { + // phpcs:disable $expected = << - title - html; + + title + html; + // phpcs:enable $result = (string) Document::create('title', 'fr', 'ascii'); @@ -156,10 +172,12 @@ public function can_change_lang_and_char_set(): void // phpcs:ignore #[Test] public function has_baseline(): void // phpcs:ignore { + // phpcs:disable $expected = << - title - html; + + title + html; + // phpcs:enable $result = (string) Document::create('title'); diff --git a/tests/ElementBaselineTest.php b/tests/ElementBaselineTest.php index afa0496..167c448 100644 --- a/tests/ElementBaselineTest.php +++ b/tests/ElementBaselineTest.php @@ -16,8 +16,8 @@ class ElementBaselineTest extends TestCase public function can_have_empty_props(): void // phpcs:ignore { $expected = <<link - html; + link + html; $result = (string) Element::a('link')->props(''); @@ -27,9 +27,11 @@ public function can_have_empty_props(): void // phpcs:ignore #[Test] public function has_ordered_properties(): void // phpcs:ignore { + // phpcs:disable $expected = <<link - html; + link + html; + // phpcs:enable $result = (string) Element::a('link')->props( 'required required', diff --git a/tests/ElementExtensionBaselineTest.php b/tests/ElementExtensionBaselineTest.php index c55bd88..93dcb69 100644 --- a/tests/ElementExtensionBaselineTest.php +++ b/tests/ElementExtensionBaselineTest.php @@ -13,9 +13,11 @@ class ElementExtensionBaselineTest extends TestCase #[Test] public function has_ordered_properties(): void // phpcs:ignore { + // phpcs:disable $exptected = <<link - html; + link + html; + // phpcs:enable $result = (string) ElementExtension::a('link')->props( 'required required', diff --git a/tests/Extensions/ElementExtension.php b/tests/Extensions/ElementExtension.php index 784fdf1..6ecfd04 100644 --- a/tests/Extensions/ElementExtension.php +++ b/tests/Extensions/ElementExtension.php @@ -13,7 +13,7 @@ class ElementExtension extends HTMLElement * element. To change the order, extend the Element class and override * this constant. */ - const ORDERED = [ + public const ORDERED = [ "is", "role", "class", // modified from default diff --git a/tests/Forms/EmailInputTest.php b/tests/Forms/EmailInputTest.php index 3eab6ad..d52602b 100644 --- a/tests/Forms/EmailInputTest.php +++ b/tests/Forms/EmailInputTest.php @@ -12,12 +12,26 @@ class EmailInputTest extends TestCase { + private static function collapsedHtml(string $html): string + { + return preg_replace('/>\s+<', $html) ?? ''; + } + #[Test] public function can_set_custom_id(): void // phpcs:ignore { - $expected = <<

Helpful text

Invalid value

+ // phpcs:disable + $html = << + + +

Helpful text

+

Invalid value

+ html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) EmailInput::create( label: 'Email', @@ -33,9 +47,16 @@ public function can_set_custom_id(): void // phpcs:ignore #[Test] public function can_set_value(): void // phpcs:ignore { - $expected = << + // phpcs:disable + $html = << + + + html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) EmailInput::create( 'Email', @@ -49,9 +70,17 @@ public function can_set_value(): void // phpcs:ignore #[Test] public function can_set_warning_message(): void // phpcs:ignore { - $expected = <<

Invalid value

+ // phpcs:disable + $html = << + + +

Invalid value

+ html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) EmailInput::create( label: 'Email', @@ -65,10 +94,15 @@ public function can_set_warning_message(): void // phpcs:ignore #[Test] public function is_expected_base(): void // phpcs:ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) EmailInput::create( 'Email', 'email' diff --git a/tests/Forms/PasswordTest.php b/tests/Forms/PasswordTest.php index e9eb046..6bdb9ad 100644 --- a/tests/Forms/PasswordTest.php +++ b/tests/Forms/PasswordTest.php @@ -10,12 +10,25 @@ class PasswordTest extends TestCase { + private static function collapsedHtml(string $html): string + { + return preg_replace('/>\s+<', $html) ?? ''; + } + #[Test] public function can_set_warning_message(): void // phpcs:ignore { - $expected = <<

Invalid value

+ // phpcs:disable + $html = << + + +

Invalid value

+ html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) Password::create( 'Password', @@ -29,10 +42,15 @@ public function can_set_warning_message(): void // phpcs:ignore #[Test] public function can_change_autocomplete(): void // phpcs:ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Password::create( 'Password', 'password' @@ -40,10 +58,15 @@ public function can_change_autocomplete(): void // phpcs:ignore parent::assertSame($expected, $result); - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Password::create( 'Password', 'password' @@ -51,10 +74,15 @@ public function can_change_autocomplete(): void // phpcs:ignore parent::assertSame($expected, $result); - $expected = << + $html = << + + + html; + self::collapsedHtml($html); + $result = (string) Password::create( 'Password', 'password' @@ -66,10 +94,15 @@ public function can_change_autocomplete(): void // phpcs:ignore #[Test] public function is_expected_base(): void // phpcs:ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) Password::create( 'Password', 'password' diff --git a/tests/Forms/SelectTest.php b/tests/Forms/SelectTest.php index f04244e..4f8d8e4 100644 --- a/tests/Forms/SelectTest.php +++ b/tests/Forms/SelectTest.php @@ -99,6 +99,7 @@ public function selection_is_driven_by_mode(): void #[Test] public function ticket_36(): void // phpcs: ignore { + // phpcs:disable $html = << Toppings @@ -112,6 +113,7 @@ public function ticket_36(): void // phpcs: ignore

Choose at least one topping.

html; + // phpcs:enable $expected = self::collapsedHtml($html); @@ -129,6 +131,7 @@ public function ticket_36(): void // phpcs: ignore #[Test] public function can_be_multiple(): void // phpcs: ignore { + // phpcs:disable $html = << @@ -141,6 +144,7 @@ public function can_be_multiple(): void // phpcs: ignore html; + // phpcs:enable $expected = self::collapsedHtml($html); diff --git a/tests/Forms/TextAreaTest.php b/tests/Forms/TextAreaTest.php index 78d1b60..4bbff94 100644 --- a/tests/Forms/TextAreaTest.php +++ b/tests/Forms/TextAreaTest.php @@ -26,8 +26,10 @@ public static function values(): iterable yield 'whitespace only' => [" \t\n "]; yield 'ampersand and quotes' => ['A & B say "hello" and \'goodbye\'']; yield 'literal entity syntax' => ['& < <']; - yield 'closing tag and script text' => ['

Outside

']; + yield 'closing tag and script text' => + ['

Outside

']; yield 'UTF-8 text' => ["Caf\u{00e9} \u{1f642}"]; } @@ -41,15 +43,23 @@ public function caller_escaped_values_round_trip_without_double_encoding(string $xpath = self::xpath($html); $textareas = $xpath->query('//*[local-name()="textarea"]'); parent::assertNotFalse($textareas); - parent::assertCount(1, $textareas, "Value must not create extra textareas.\nRendered HTML:\n" . $html); + parent::assertCount( + 1, + $textareas, + "Value must not create extra textareas.\nRendered HTML:\n" . $html + ); $textarea = $textareas->item(0); parent::assertInstanceOf(DomElement::class, $textarea); parent::assertSame( $value, $textarea->textContent, - "Caller-escaped text must round-trip without trimming or double encoding.\nRendered HTML:\n" . $html + "Caller-escaped text must round-trip without trimming or double " + . "encoding.\nRendered HTML:\n" . $html + ); + parent::assertFalse( + $textarea->hasAttribute('value'), + 'Textarea values belong in text content.' ); - parent::assertFalse($textarea->hasAttribute('value'), 'Textarea values belong in text content.'); parent::assertSame( 0, $xpath->query('//*[local-name()="script" or local-name()="p"]')->length, @@ -60,7 +70,11 @@ public function caller_escaped_values_round_trip_without_double_encoding(string $html, 'The supplied value must be rendered verbatim after the parser-consumed newline.' ); - parent::assertSame($html, (string) $control, 'Repeated rendering must not transform the supplied value.'); + parent::assertSame( + $html, + (string) $control, + 'Repeated rendering must not transform the supplied value.' + ); } /** @return iterable */ @@ -114,7 +128,8 @@ public function carriage_returns_follow_native_html_newline_normalization(): voi parent::assertSame( "\nFirst\nSecond\n", $node->textContent, - 'HTML parsing normalizes CR and CRLF to LF without losing leading or trailing line breaks.' + 'HTML parsing normalizes CR and CRLF to LF without losing ' + . 'leading or trailing line breaks.' ); } @@ -134,17 +149,38 @@ public function native_properties_and_custom_identity_target_the_textarea(): voi $xpath = self::xpath($html); $textarea = $xpath->query('//*[local-name()="textarea"]')->item(0); parent::assertInstanceOf(DomElement::class, $textarea); - parent::assertSame('custom-notes', $textarea->getAttribute('id'), 'The explicit ID must win.'); - parent::assertSame('notes', $textarea->getAttribute('name'), 'The declared submitted name must win.'); + parent::assertSame( + 'custom-notes', + $textarea->getAttribute('id'), + 'The explicit ID must win.' + ); + parent::assertSame( + 'notes', + $textarea->getAttribute('name'), + 'The declared submitted name must win.' + ); parent::assertSame('4', $textarea->getAttribute('rows')); - foreach (['required', 'readonly', 'disabled'] as $attribute) { - parent::assertTrue($textarea->hasAttribute($attribute), $attribute . ' must reach the textarea.'); + + $attributes = ['required', 'readonly', 'disabled']; + foreach ($attributes as $attribute) { + parent::assertTrue( + $textarea->hasAttribute($attribute), + $attribute . ' must reach the textarea.' + ); } - parent::assertFalse($textarea->hasAttribute('aria-invalid'), 'No warning must mean no generated invalid state.'); - parent::assertFalse($textarea->hasAttribute('aria-describedby'), 'No guidance must mean no generated references.'); + parent::assertFalse( + $textarea->hasAttribute('aria-invalid'), + 'No warning must mean no generated invalid state.' + ); + parent::assertFalse( + $textarea->hasAttribute('aria-describedby'), + 'No guidance must mean no generated references.' + ); parent::assertSame( 1, - $xpath->query('//*[local-name()="label" and @for="custom-notes" and @class="notes-label"]')->length, + $xpath->query( + '//*[local-name()="label" and @for="custom-notes" and @class="notes-label"]' + )->length, 'The styled label must reference the custom control ID.' ); parent::assertSame( @@ -153,10 +189,16 @@ public function native_properties_and_custom_identity_target_the_textarea(): voi ); parent::assertSame( 0, - $xpath->query('//*[local-name()="div" and (@rows or @required or @readonly or @disabled)]')->length, + $xpath->query( + '//*[local-name()="div" and (@rows or @required or @readonly or @disabled)]' + )->length, 'Native control attributes must not leak onto the wrapper.' ); - parent::assertStringContainsString('A & B', $html, 'Caller-escaped ampersands must remain unchanged.'); + parent::assertStringContainsString( + 'A & B', + $html, + 'Caller-escaped ampersands must remain unchanged.' + ); } private static function xpath(string $html): XPath diff --git a/tests/Forms/TextInputTest.php b/tests/Forms/TextInputTest.php index 4f4c9b4..af0078a 100644 --- a/tests/Forms/TextInputTest.php +++ b/tests/Forms/TextInputTest.php @@ -12,12 +12,26 @@ class TextInputTest extends TestCase { + private static function collapsedHtml(string $html): string + { + return preg_replace('/>\s+<', $html) ?? ''; + } + #[Test] public function can_set_custom_id(): void // phpcs:ignore { - $expected = <<

Helpful text

Invalid value

+ // phpcs:disable + $html = << + + +

Helpful text

+

Invalid value

+ html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) TextInput::create( label: 'Text', @@ -33,10 +47,15 @@ public function can_set_custom_id(): void // phpcs:ignore #[Test] public function can_set_value(): void // phpcs:ignore { - $expected = << + $html = << + + + html; + $expected = self::collapsedHtml($html); + $result = (string) TextInput::create( 'Text', 'text', @@ -49,9 +68,17 @@ public function can_set_value(): void // phpcs:ignore #[Test] public function can_set_warning_message(): void // phpcs:ignore { - $expected = <<

Invalid value

+ // phpcs:disable + $html = << + + +

Invalid value

+ html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) TextInput::create( label: 'Text', From 7e5db2de78f7495e37c3d4f2a646fd46283ee0dd Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Tue, 10 Nov 2026 21:59:00 -0500 Subject: [PATCH 27/27] fix: All errors --- bin/functions.php | 83 ++++++++++ bin/generate-element-docblock.php | 261 ++++++++++-------------------- tests/ControlGuidanceTest.php | 7 +- tests/Forms/PasswordTest.php | 2 +- 4 files changed, 179 insertions(+), 174 deletions(-) create mode 100644 bin/functions.php diff --git a/bin/functions.php b/bin/functions.php new file mode 100644 index 0000000..a66f572 --- /dev/null +++ b/bin/functions.php @@ -0,0 +1,83 @@ + + */ +function getElementEntries(string $source): array +{ + $json = file_get_contents($source); + if ($json === false) { + fwrite(STDERR, "Could not read: {$source}\n"); + exit(1); + } + + if (json_validate($json) === false) { + fwrite(STDERR, "Invalid JSON in: {$source}\n"); + exit(1); + } + + $entries = json_decode($json, false); + if (is_array($entries) === false) { + fwrite(STDERR, "JSON not array of element objects: {$source}\n"); + exit(1); + } + + $e = []; + foreach ($entries as $entry) { + if ( + is_object($entry) === false + or is_a($entry, stdClass::class) === false + or property_exists($entry, 'element') === false + or is_string($entry->element) === false + or property_exists($entry, 'specs') === false + ) { + fwrite(STDERR, "JSON not array of valid objects: {$source}\n"); + exit(1); + } + $e[] = $entry; + } + + return $e; +} + +/** + * @param list $entries + * @return array> + */ +function getSpecsByElement(array $entries): array +{ + // The reference lists input once per type attribute (input[type="hidden"], + // etc.); the builder dispatches on tag name only, so collapse to `input`. + $specsByName = []; + foreach ($entries as $entry) { + $element = $entry->element; + if (is_string($element) and str_contains($element, '[')) { + // TODO: convert to pipe when PHP 8.5 required + // $element = explode('[', $element) |> 'array_first'; + $element = explode('[', $element); + $element = $element[0]; + } + + if (is_string($element) === false) { + $json = json_encode($entry); + fwrite(STDERR, "Element not a string: {$json}\n"); + exit(1); + } + + if (array_key_exists($element, $specsByName) === false) { + if (is_array($entry->specs) === false) { + fwrite(STDERR, "'specs' not array: {$element}\n"); + exit(1); + } + // TODO: convert to pipe when PHP 8.5 required |> 'array_values'; + $specsByName[$element] = array_values( + array_filter($entry->specs, 'is_string') + ); + } + } + return $specsByName; +} diff --git a/bin/generate-element-docblock.php b/bin/generate-element-docblock.php index f246f98..c563fe9 100644 --- a/bin/generate-element-docblock.php +++ b/bin/generate-element-docblock.php @@ -17,200 +17,117 @@ * (default: https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json) */ -use stdClass; +require_once __DIR__ . 'functions.php'; -/** - * @return list - */ -function getElementEntries(string $source): array -{ - $json = file_get_contents($source); - if ($json === false) { - fwrite(STDERR, "Could not read: {$source}\n"); - exit(1); - } +$source = $argv[2] ?? + 'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json'; - if (json_validate($json) === false) { - fwrite(STDERR, "Invalid JSON in: {$source}\n"); - exit(1); - } +$entries = getElementEntries($source); - $entries = json_decode($json, false); - if (is_array($entries) === false) { - fwrite(STDERR, "JSON not array of element objects: {$source}\n"); - exit(1); - } +$specsByElement = getSpecsByElement($entries); - $e = []; - foreach ($entries as $entry) { - if ( - is_object($entry) === false - or is_a($entry, stdClass::class) === false - or property_exists($entry, 'element') === false - or is_string($entry->element) === false - or property_exists($entry, 'specs') === false - ) { - fwrite(STDERR, "JSON not array of valid objects: {$source}\n"); - exit(1); - } - $e[] = $entry; - } +$variantCount = count($entries) - count($specsByElement); - return $e; -} - -/** - * @param list $entries - * @return array> - */ -function getSpecsByElement(array $entries): array -{ - // The reference lists input once per type attribute (input[type="hidden"], - // etc.); the builder dispatches on tag name only, so collapse to `input`. - $specsByName = []; - foreach ($entries as $entry) { - $element = $entry->element; - if (is_string($element) and str_contains($element, '[')) { - // TODO: convert to pipe when PHP 8.5 required - // $element = explode('[', $element) |> 'array_first'; - $element = explode('[', $element); - $element = $element[0]; - } - - if (is_string($element) === false) { - $json = json_encode($entry); - fwrite(STDERR, "Element not a string: {$json}\n"); - exit(1); - } +// Group by era: current (in the newest spec) first, then deprecated groups +// ordered by how recently they were dropped, with an unspecified tail group +// for entries the source gives no spec data. +$eraOrder = ['2.0', '3.2', '4.01', 'X1.0', 'X1.1', '5', '5.1', '5.2']; +$current = end($eraOrder); - if (array_key_exists($element, $specsByName) === false) { - if (is_array($entry->specs) === false) { - fwrite(STDERR, "'specs' not array: {$element}\n"); - exit(1); - } - // TODO: convert to pipe when PHP 8.5 required |> 'array_values'; - $specsByName[$element] = array_values( - array_filter($entry->specs, 'is_string') - ); +$groups = []; // key: last-appearance index in $eraOrder, or -1 for unspecified +foreach ($specsByElement as $element => $specs) { + if ($specs === []) { + $groups[-1][] = $element; + continue; + } + $last = -1; + foreach ($specs as $spec) { + $index = array_search($spec, $eraOrder, true); + if ($index !== false && $index > $last) { + $last = $index; } } - return $specsByName; + $groups[$last][] = $element; } -function main(): void -{ - $source = $argv[2] ?? - 'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json'; +krsort($groups); // newest last-appearance first; -1 (unspecified) lands last + +$lines = []; +$lines[] = '/**'; +$lines[] = ' * Magic static element factories. Generated from w3c/elements-of-html'; +$lines[] = sprintf( + ' * elements.json (retrieved %s): %d entries, %d unique elements after', + date('Y-m-d'), + count($entries), + count($specsByElement), +); +$lines[] = sprintf( + ' * collapsing the %d input[type=...] variants into input. Sectioned by', + $variantCount, +); +$lines[] = ' * era: current elements first, then deprecated groups by when they were'; +$lines[] = ' * dropped, each group alphabetical.'; +$lines[] = ' *'; +$lines[] = ' * Element semantics per the WHATWG HTML living standard:'; +$lines[] = ' * https://html.spec.whatwg.org'; +$lines[] = ' *'; + +$firstRun = true; +foreach ($groups as $lastIndex => $elements) { + sort($elements); + if ($firstRun === false) { + $lines[] = ' *'; - $entries = getElementEntries($source); - - $specsByElement = getSpecsByElement($entries); + } + $firstRun = false; - $variantCount = count($entries) - count($specsByElement); + if ($lastIndex === -1) { + $lines[] = ' * No spec data in source (verify before relying on these):'; - // Group by era: current (in the newest spec) first, then deprecated groups - // ordered by how recently they were dropped, with an unspecified tail group - // for entries the source gives no spec data. - $eraOrder = ['2.0', '3.2', '4.01', 'X1.0', 'X1.1', '5', '5.1', '5.2']; - $current = end($eraOrder); + } elseif ($eraOrder[$lastIndex] === $current) { + $lines[] = sprintf(' * Available in %s (current):', $current); - $groups = []; // key: last-appearance index in $eraOrder, or -1 for unspecified - foreach ($specsByElement as $element => $specs) { - if ($specs === []) { - $groups[-1][] = $element; - continue; + } else { + // The XHTML 1.x line runs parallel to HTML 4.01; an element dropped + // after X1.0/X1.1 was typically still available in 4.01, so list both. + $lastAvailable = [$eraOrder[$lastIndex]]; + if (str_starts_with($eraOrder[$lastIndex], 'X1')) { + array_unshift($lastAvailable, '4.01'); } - $last = -1; - foreach ($specs as $spec) { - $index = array_search($spec, $eraOrder, true); - if ($index !== false && $index > $last) { - $last = $index; - } - } - $groups[$last][] = $element; - } - krsort($groups); // newest last-appearance first; -1 (unspecified) lands last - - $lines = []; - $lines[] = '/**'; - $lines[] = ' * Magic static element factories. Generated from w3c/elements-of-html'; - $lines[] = sprintf( - ' * elements.json (retrieved %s): %d entries, %d unique elements after', - date('Y-m-d'), - count($entries), - count($specsByElement), - ); - $lines[] = sprintf( - ' * collapsing the %d input[type=...] variants into input. Sectioned by', - $variantCount, - ); - $lines[] = ' * era: current elements first, then deprecated groups by when they were'; - $lines[] = ' * dropped, each group alphabetical.'; - $lines[] = ' *'; - $lines[] = ' * Element semantics per the WHATWG HTML living standard:'; - $lines[] = ' * https://html.spec.whatwg.org'; - $lines[] = ' *'; - - $firstRun = true; - foreach ($groups as $lastIndex => $elements) { - sort($elements); - if ($firstRun === false) { - $lines[] = ' *'; - - } - $firstRun = false; - - if ($lastIndex === -1) { - $lines[] = ' * No spec data in source (verify before relying on these):'; - - } elseif ($eraOrder[$lastIndex] === $current) { - $lines[] = sprintf(' * Available in %s (current):', $current); - - } else { - // The XHTML 1.x line runs parallel to HTML 4.01; an element dropped - // after X1.0/X1.1 was typically still available in 4.01, so list both. - $lastAvailable = [$eraOrder[$lastIndex]]; - if (str_starts_with($eraOrder[$lastIndex], 'X1')) { - array_unshift($lastAvailable, '4.01'); - } - - $lastAvailableIndex = $lastIndex + 1; - if (isset($eraOrder[$lastAvailableIndex]) === false) { - fwrite(STDERR, "Could not get last spec available for last index: {$lastIndex}\n"); - exit(1); - } - - $lines[] = sprintf( - ' * Deprecated in %s; last available in %s:', - $eraOrder[$lastAvailableIndex], - implode(', ', $lastAvailable), - ); + $lastAvailableIndex = $lastIndex + 1; + if (isset($eraOrder[$lastAvailableIndex]) === false) { + fwrite(STDERR, "Could not get last spec available for last index: {$lastIndex}\n"); + exit(1); } - foreach ($elements as $element) { - $lines[] = sprintf( - ' * @method static static %s(string|\Stringable ...$content)', - $element - ); - } + $lines[] = sprintf( + ' * Deprecated in %s; last available in %s:', + $eraOrder[$lastAvailableIndex], + implode(', ', $lastAvailable), + ); } - $lines[] = ' */'; + foreach ($elements as $element) { + $lines[] = sprintf( + ' * @method static static %s(string|\Stringable ...$content)', + $element + ); - $docblock = implode("\n", $lines) . "\n"; - - $target = $argv[1] ?? null; - if ($target === null) { - echo $docblock; - exit(0); } +} +$lines[] = ' */'; - if (file_put_contents(__DIR__ . '/../docblocks/' . $target, $docblock) === false) { - fwrite(STDERR, "Could not write: {$target}\n"); - exit(1); - } +$docblock = implode("\n", $lines) . "\n"; + +$target = $argv[1] ?? null; +if ($target === null) { + echo $docblock; + exit(0); +} - fwrite(STDOUT, sprintf("Wrote %d lines to %s\n", count($lines), $target)); +if (file_put_contents(__DIR__ . '/../docblocks/' . $target, $docblock) === false) { + fwrite(STDERR, "Could not write: {$target}\n"); + exit(1); } -main(); +fwrite(STDOUT, sprintf("Wrote %d lines to %s\n", count($lines), $target)); diff --git a/tests/ControlGuidanceTest.php b/tests/ControlGuidanceTest.php index 18eef3f..2347d8f 100644 --- a/tests/ControlGuidanceTest.php +++ b/tests/ControlGuidanceTest.php @@ -228,17 +228,21 @@ private static function assertGuidance( 'Without help or a warning, aria-describedby must be absent. ' . $controlContext ); + } else { + parent::assertIsString($control->getAttribute('aria-describedby')); parent::assertSame( $expectedIds, preg_split( '/\s+/', trim($control->getAttribute('aria-describedby')) - ), // @phpstan-ignore argument.type + ), 'aria-describedby must preserve help IDs and append the warning ID once ' . ', in order. ' . $controlContext ); + } + if ($hasWarning) { parent::assertSame( 'true', @@ -246,6 +250,7 @@ private static function assertGuidance( 'A warning must set aria-invalid="true", overriding any ' . 'supplied valid state. ' . $controlContext ); + } else { parent::assertFalse( $control->hasAttribute('aria-invalid'), diff --git a/tests/Forms/PasswordTest.php b/tests/Forms/PasswordTest.php index 6bdb9ad..547ad02 100644 --- a/tests/Forms/PasswordTest.php +++ b/tests/Forms/PasswordTest.php @@ -81,7 +81,7 @@ public function can_change_autocomplete(): void // phpcs:ignore html; - self::collapsedHtml($html); + $expected = self::collapsedHtml($html); $result = (string) Password::create( 'Password',