Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7bbd2a3
config: Update composer dependency list and remove unneeded cast
joshbruce Nov 10, 2026
0851363
feat: Docblock builder for elements
joshbruce Nov 10, 2026
13effbf
feat: Element passes PHPStan L10, no ignored errors, fixed incorrect …
joshbruce Nov 10, 2026
e3bb79c
feat: EmptyElement added only used in one test case
joshbruce Nov 10, 2026
ad8eb8e
fix: GitHub workflows
joshbruce Nov 10, 2026
4b006c1
feat: Empty element docblock bin
joshbruce Nov 10, 2026
3d49d6b
red: Has failing empty element test
joshbruce Nov 10, 2026
6835a61
red: Expect PHPtan to fail because H1 not in valid empty element list
joshbruce Nov 10, 2026
39cdf71
red: Add tests/ to static analysis; fix performance test
joshbruce Nov 10, 2026
9c1254c
red: Fix dynamic call of static method and static analysis issues
joshbruce Nov 10, 2026
c105e71
red: FieldsetTest passes static analysis
joshbruce Nov 10, 2026
382eb28
red: Delete ContainerPropsTest as unnecessary
joshbruce Nov 10, 2026
099340b
red: Fix PHPStan dynamic call
joshbruce Nov 10, 2026
c93a968
red: Fix ElementBaseline and add EmptyElement check
joshbruce Nov 10, 2026
09e3ca5
red: All pass tests/ pass PHPStan
joshbruce Nov 10, 2026
de7794c
add: tests/ to style check
joshbruce Nov 11, 2026
78c6064
feat: Include bin/ in static analysis, refactor script
joshbruce Nov 11, 2026
9ae0db0
feat: Add bin/, fix errors, fix tests
joshbruce Nov 11, 2026
d7a380d
fix: Support PHP 8.4
joshbruce Nov 11, 2026
c5eb7a0
fix: parse error
joshbruce Nov 11, 2026
d005846
run: PHP 8.4 fixes
joshbruce Nov 11, 2026
9acd8af
fix: PHP 8.4
joshbruce Nov 11, 2026
10640bb
update: Workflow dependencies
joshbruce Nov 11, 2026
2f5d123
docs: Update generator docblock and composer to use PHPCS directly
joshbruce Nov 11, 2026
7837424
red: Fix most style errors
joshbruce Nov 11, 2026
45513f2
red: Style failures
joshbruce Nov 11, 2026
7e5db2d
fix: All errors
joshbruce Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/php84.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
10 changes: 5 additions & 5 deletions .github/workflows/php85.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
83 changes: 83 additions & 0 deletions bin/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

use stdClass;

/**
* @return list<stdClass>
*/
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<stdClass> $entries
* @return array<string, list<string>>
*/
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;
}
133 changes: 133 additions & 0 deletions bin/generate-element-docblock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

/**
* Generates the @method static docblock for Element from the W3C
* elements-of-html element reference.
*
* Authored by GitHub Copilot (session 122325ca-bce6-4353-83ce-83f97b778344), 2026-11-10.
*
* Refactored by Josh Bruce, 2026-11-10.
*
* Usage: php generate-element-docblock.php [output.php] [elements.json]
* output.php Where to write the docblock (default: stdout)
*
* elements.json Path or URL to the elements reference
* (default: https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json)
*/

require_once __DIR__ . 'functions.php';

$source = $argv[2] ??
'https://raw.githubusercontent.com/w3c/elements-of-html/master/elements.json';

$entries = getElementEntries($source);

$specsByElement = getSpecsByElement($entries);

$variantCount = count($entries) - count($specsByElement);

// Group by era: current (in the newest spec) first, then deprecated groups
// ordered by how recently they were dropped, with an unspecified tail group
// for entries the source gives no spec data.
$eraOrder = ['2.0', '3.2', '4.01', 'X1.0', 'X1.1', '5', '5.1', '5.2'];
$current = end($eraOrder);

$groups = []; // key: last-appearance index in $eraOrder, or -1 for unspecified
foreach ($specsByElement as $element => $specs) {
if ($specs === []) {
$groups[-1][] = $element;
continue;
}
$last = -1;
foreach ($specs as $spec) {
$index = array_search($spec, $eraOrder, true);
if ($index !== false && $index > $last) {
$last = $index;
}
}
$groups[$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));
70 changes: 70 additions & 0 deletions bin/generate-empty-element-docblock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

/**
* Generates the @method static docblock for EmptyElement from the WHATWG HTML
* living standard one-page spec: an element is void when its definition's
* "Tag omission in text/html" entry reads "No end tag." Full pipeline in one
* script (derive + emit); derive-void-elements.php remains as the standalone
* list verifier.
*
* Authored by GitHub Copilot (session 122325ca-bce6-4353-83ce-83f97b778344), 2026-11-10.
*
* Usage: php generate-empty-element-docblock.php [output.php] [spec.html]
* output.php Where to write the docblock (default: stdout)
* spec.html Path or URL to the one-page spec
* (default: https://html.spec.whatwg.org/)
*/

$source = $argv[2] ?? 'https://html.spec.whatwg.org/';

$html = file_get_contents($source);
if ($html === false) {
fwrite(STDERR, "Could not read: {$source}\n");
exit(1);
}

// Spec markup: <dd>No <a href="#syntax-end-tag"
// id="the-img-element:syntax-end-tag">end tag</a>.</dd>
$pattern = '/>No <a href="#syntax-end-tag" id="the-([a-z0-9]+)-element:/';
if (preg_match_all($pattern, $html, $matches) === false || $matches[1] === []) {
fwrite(STDERR, "No void elements found; the spec markup may have changed.\n");
exit(1);
}

$names = array_values(array_unique($matches[1]));
sort($names);

$lines = [];
$lines[] = '/**';
$lines[] = ' * Magic static void-element factories. Generated from the WHATWG HTML';
$lines[] = sprintf(
' * living standard (https://html.spec.whatwg.org/, retrieved %s):',
date('Y-m-d'),
);
$lines[] = sprintf(
' * %d void elements, derived from each element\'s "Tag omission in',
count($names),
);
$lines[] = ' * text/html: No end tag." entry. Alphabetical.';
$lines[] = ' *';
foreach ($names as $name) {
$lines[] = sprintf(' * @method static static %s(string ...$properties)', $name);
}
$lines[] = ' */';

$docblock = implode("\n", $lines) . "\n";

$target = $argv[1] ?? null;
if ($target === null) {
echo $docblock;
exit(0);
}

if (file_put_contents(__DIR__ . '/../docblocks/' . $target, $docblock) === false) {
fwrite(STDERR, "Could not write: {$target}\n");
exit(1);
}

fwrite(STDOUT, sprintf("Wrote %d lines to %s\n", count($lines), $target));
28 changes: 20 additions & 8 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
"php": "^8.4"
},
"require-dev": {
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^13.2",
"phpstan/extension-installer": "*",
"phpstan/phpstan": "^2.3",
"phpstan/phpstan-strict-rules": "^2.1",
"phpunit/phpunit": "^13.4",
"spaze/phpstan-disallowed-calls": "^4.15",
"squizlabs/php_codesniffer": "^4.0"
},
"autoload": {
Expand All @@ -28,14 +31,23 @@
}
},
"config": {
"preferred-install": "dist"
"preferred-install": "dist",
"allow-plugins": {
"phpstan/extension-installer": true
}
},
"prefer-stable": true,
"scripts": {
"prod": "@production",
"production": ["@style", "@stan", "@test"],
"style": "./vendor/bin/phpcs --standard=phpcs.xml -p ./src",
"stan": "./vendor/bin/phpstan analyze",
"test": "./vendor/bin/phpunit --display-all-issues"
"prod": "@check",
"production": "@check",
"check": [
"@check:style",
"@check:analysis",
"@check:test"
],
"check:style": "./vendor/bin/phpcs --standard=phpcs.xml",
"check:analysis": "./vendor/bin/phpstan analyze",
"check:test": "./vendor/bin/phpunit --display-all-issues",
"build:docblocks": "php ./bin/generate-element-docblock.php elements-generated.txt && php ./bin/generate-empty-element-docblock.php empty-elements-generated.txt"
}
}
Loading
Loading