diff --git a/.github/workflows/php84.yml b/.github/workflows/php84.yml index 34af196..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' @@ -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..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' @@ -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/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 new file mode 100644 index 0000000..c563fe9 --- /dev/null +++ b/bin/generate-element-docblock.php @@ -0,0 +1,133 @@ + $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; +} + +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), + ); + } + foreach ($elements as $element) { + $lines[] = sprintf( + ' * @method static static %s(string|\Stringable ...$content)', + $element + ); + + } +} +$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/bin/generate-empty-element-docblock.php b/bin/generate-empty-element-docblock.php new file mode 100644 index 0000000..640f884 --- /dev/null +++ b/bin/generate-empty-element-docblock.php @@ -0,0 +1,70 @@ +No end tag. +$pattern = '/>No =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/docblocks/elements-generated.txt b/docblocks/elements-generated.txt new file mode 100644 index 0000000..30abaff --- /dev/null +++ b/docblocks/elements-generated.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/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/phpcs.xml b/phpcs.xml index 603b7cf..f3bd014 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -3,6 +3,8 @@ PHP-FIG PSR-12 standards with modifications to: control structure (empty space before closing brace). src + tests + bin @@ -13,6 +15,12 @@ + + + + */tests/* + + diff --git a/phpstan.neon b/phpstan.neon index d305d34..4be41e3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,6 @@ parameters: - level: 9 + level: 10 paths: - src - ignoreErrors: - - '#Call to an undefined static method Eightfold\\HtmlBuilder\\Element::(.*)#' + - tests + - bin diff --git a/src/Components/Favicons.php b/src/Components/Favicons.php index 567d1f5..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' ); @@ -184,6 +184,6 @@ public function __toString(): string 'content ' . $this->appName() ); } - return (string) implode('', $elements); + return implode('', $elements); } } diff --git a/src/Element.php b/src/Element.php index 5d2512f..a30517c 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 { /** @@ -66,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, @@ -78,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..4b5203c --- /dev/null +++ b/src/EmptyElement.php @@ -0,0 +1,79 @@ + $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/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/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 6455df6..07f89b5 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; @@ -23,16 +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]; } } } @@ -48,7 +67,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) }; @@ -75,7 +94,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') ); } @@ -91,9 +110,11 @@ public static function labelCases(): iterable #[Test] #[DataProvider('labelCases')] - public function generated_label_association_overrides_caller_properties(string $mode, ?string $id): void - { - $control = match ($mode) { + 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'), 'password' => Password::create('Field', 'field'), diff --git a/tests/ControlGuidanceTest.php b/tests/ControlGuidanceTest.php index af1fcd2..2347d8f 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]; } } @@ -143,20 +153,26 @@ 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), '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() }; @@ -190,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, @@ -205,36 +225,54 @@ 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::assertIsString($control->getAttribute('aria-describedby')); parent::assertSame( $expectedIds, - preg_split('/\s+/', trim($control->getAttribute('aria-describedby'))), - 'aria-describedby must preserve help IDs and append the warning ID once, in order. ' - . $controlContext + preg_split( + '/\s+/', + trim($control->getAttribute('aria-describedby')) + ), + '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, @@ -249,19 +287,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 9b0c59d..5eaf333 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; @@ -17,17 +18,19 @@ 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']), 'en-us' )->htmlProps('class dark-theme', 'lang fr'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -36,16 +39,18 @@ 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']) )->htmlProps('class dark-theme'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -54,16 +59,18 @@ 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']) )->bodyProps('style color: #000;', 'class world', 'id hello'); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -72,16 +79,18 @@ 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']) ); - $this->assertSame( + parent::assertSame( $expected, $result ); @@ -90,81 +99,88 @@ 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( - Element::link() - ->omitEndTag()->props('rel stylesheet', 'href style.css'), - Element::script()->props('src script.js') - )->body( - Element::p('paragraph content') - ); - - $this->assertSame($expected, $result); + EmptyElement::link()->props('rel stylesheet', 'href style.css'), + Element::script()->props('src script.js') + )->body( + Element::p('paragraph content') + ); + + parent::assertSame($expected, $result); } #[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( - Element::link() - ->omitEndTag()->props('rel stylesheet', 'href style.css'), - Element::script()->props('src script.js') - )->body( - Element::p('paragraph content') - ); - - $this->assertSame($expected, $result); + EmptyElement::link()->props('rel stylesheet', 'href style.css'), + Element::script()->props('src script.js') + )->body( + Element::p('paragraph content') + ); + + parent::assertSame($expected, $result); } #[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( - Element::link() - ->omitEndTag()->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') + ); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[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'); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[Test] public function has_baseline(): void // phpcs:ignore { + // phpcs:disable $expected = << - title - html; + + title + html; + // phpcs:enable $result = (string) Document::create('title'); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } } diff --git a/tests/ElementBaselineTest.php b/tests/ElementBaselineTest.php index 6bb7c19..167c448 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 { @@ -14,41 +16,47 @@ class ElementBaselineTest extends TestCase public function can_have_empty_props(): void // phpcs:ignore { $expected = <<link
- html; + link + html; $result = (string) Element::a('link')->props(''); - $this->assertSame($expected, $result); + parent::assertSame($expected, $result); } #[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', - 'href https://8fold.pro', - 'class some-style', - 'id unique', - 'data-testing test' - ); - - $this->assertSame($expected, $result); + 'required required', + 'href https://8fold.pro', + 'class some-style', + 'id unique', + 'data-testing test' + ); + + 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); } } diff --git a/tests/ElementExtensionBaselineTest.php b/tests/ElementExtensionBaselineTest.php index f7e24bc..93dcb69 100644 --- a/tests/ElementExtensionBaselineTest.php +++ b/tests/ElementExtensionBaselineTest.php @@ -13,19 +13,21 @@ 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', - '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' + ); - $this->assertSame($exptected, $result); + parent::assertSame($exptected, $result); } } diff --git a/tests/EmptyElementBaselineTest.php b/tests/EmptyElementBaselineTest.php new file mode 100644 index 0000000..2e29563 --- /dev/null +++ b/tests/EmptyElementBaselineTest.php @@ -0,0 +1,24 @@ + + html; + + $result = (string) EmptyElement::img(); + + parent::assertSame($expected, $result); + } +} 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/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]); - } -} 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/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); } diff --git a/tests/Forms/PasswordTest.php b/tests/Forms/PasswordTest.php index e9eb046..547ad02 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; + $expected = 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 69c645e..4f8d8e4 100644 --- a/tests/Forms/SelectTest.php +++ b/tests/Forms/SelectTest.php @@ -16,213 +16,106 @@ 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.

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

Choose at least one topping.

html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) Select::create( 'Toppings', @@ -238,9 +131,22 @@ public function ticket_36(): void // phpcs: ignore #[Test] public function can_be_multiple(): void // phpcs: ignore { - $expected = <<
  • Invalid value
+ // phpcs:disable + $html = << + + +
    +
  • Invalid value
  • +
+ html; + // phpcs:enable + + $expected = self::collapsedHtml($html); $result = (string) Select::create( name: 'select', @@ -260,10 +166,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 +199,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 +228,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 +251,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 +278,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 +309,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 +341,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 +368,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 +393,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 +420,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 +445,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 +473,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 +498,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', diff --git a/tests/Forms/TextAreaTest.php b/tests/Forms/TextAreaTest.php index 8d11449..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 */ @@ -84,9 +98,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,11 +121,15 @@ 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, - 'HTML parsing normalizes CR and CRLF to LF without losing leading or trailing line breaks.' + $node->textContent, + 'HTML parsing normalizes CR and CRLF to LF without losing ' + . 'leading or trailing line breaks.' ); } @@ -128,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( @@ -147,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', diff --git a/tests/PerformanceTest.php b/tests/PerformanceTest.php index d2124ee..d4c7779 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; + $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); + $kb = round($used / 1024.2); - $this->assertLessThan(66, $kb); + parent::assertLessThan(66, $kb); } }