diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 347cf26..c350ee1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,33 @@ name: CI on: push: + branches: [master] pull_request: env: - COVERAGE_PHP_VERSION: "8.3" + PSALM_PHP_VERSION: "8.4" + COVERAGE_PHP_VERSION: "8.4" jobs: + psalm: + name: Psalm + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PSALM_PHP_VERSION }} + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run Psalm + run: vendor/bin/psalm --no-progress + phpunit: name: PHPUnit runs-on: ubuntu-latest @@ -16,17 +37,14 @@ jobs: fail-fast: false matrix: php-version: - - "7.2" - - "7.3" - - "7.4" - - "8.0" - "8.1" - "8.2" - "8.3" + - "8.4" deps: - "highest" include: - - php-version: "7.2" + - php-version: "8.1" deps: "lowest" steps: diff --git a/README.md b/README.md index 6babfb0..8dc026e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ composer require brick/structured-data ### Requirements -This library requires PHP 7.2 or later. It makes use of the following extensions: +This library requires PHP 8.1 or later. It makes use of the following extensions: - [dom](https://www.php.net/manual/en/book.dom.php) - [json](https://www.php.net/manual/en/book.json.php) @@ -39,7 +39,7 @@ optimizing existing code, etc.), `y` is incremented. **When a breaking change is introduced, a new `0.x` version cycle is always started.** -It is therefore safe to lock your project to a given release cycle, such as `0.1.*`. +It is therefore safe to lock your project to a given release cycle, such as `0.2.*`. If you need to upgrade to a newer release cycle, check the [release history](https://github.com/brick/structured-data/releases) for a list of changes introduced by each further `0.x.0` version. diff --git a/composer.json b/composer.json index 6866213..0827ad7 100644 --- a/composer.json +++ b/composer.json @@ -11,15 +11,16 @@ ], "license": "MIT", "require": { - "php": "^7.2 || ^8.0", + "php": "^8.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", - "sabre/uri": "^2.1" + "sabre/uri": "^2.1 || ^3.0" }, "require-dev": { "phpunit/phpunit": "^8.0 || ^9.0", - "php-coveralls/php-coveralls": "^2.0" + "php-coveralls/php-coveralls": "^2.0", + "vimeo/psalm": "6.12.0" }, "autoload": { "psr-4": { diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..16e2cec --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + $this->readJson($node->textContent, $url)]]> + + + + + + + + + + + parentNode; + + if ($itemprop->isSameNode($node)) { + return true; + } + + if ($itemprop->attributes->getNamedItem('itemscope')) { + return false; + } + } + }]]> + + + $this->nodeToItem($node, $xpath, $url)]]> + parentNode; + + if ($itemprop->isSameNode($node)) { + return true; + } + + if ($itemprop->attributes->getNamedItem('itemscope')) { + return false; + } + } + }]]> + + + + textContent)]]> + + + attributes->getNamedItem('itemprop')->textContent]]> + + + + + + + + + + + + parentNode; + + if ($itemprop->isSameNode($node)) { + return true; + } + + if ($itemprop->attributes->getNamedItem('typeof')) { + return false; + } + } + + // Unreachable, but makes static analysis happy + return false; + }]]> + + + $this->nodeToItem($node, $xpath, $url, self::PREDEFINED_PREFIXES, null)]]> + parentNode; + + if ($itemprop->isSameNode($node)) { + return true; + } + + if ($itemprop->attributes->getNamedItem('typeof')) { + return false; + } + } + + // Unreachable, but makes static analysis happy + return false; + }]]> + + + + textContent]]> + textContent)]]> + + + attributes->getNamedItem('property')->textContent]]> + textContent]]> + + + + + + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..b99bd54 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/src/DOMBuilder.php b/src/DOMBuilder.php index f1fbddb..78bc882 100644 --- a/src/DOMBuilder.php +++ b/src/DOMBuilder.php @@ -6,7 +6,7 @@ use DOMDocument; -class DOMBuilder +final class DOMBuilder { /** * Builds a DOMDocument from an HTML string. diff --git a/src/HTMLReader.php b/src/HTMLReader.php index 529d4a2..d28c95b 100644 --- a/src/HTMLReader.php +++ b/src/HTMLReader.php @@ -4,12 +4,9 @@ namespace Brick\StructuredData; -class HTMLReader +final class HTMLReader { - /** - * @var Reader - */ - private $reader; + private readonly Reader $reader; /** * HTMLReader constructor. diff --git a/src/Item.php b/src/Item.php index 0255fcd..dea1a36 100644 --- a/src/Item.php +++ b/src/Item.php @@ -4,33 +4,29 @@ namespace Brick\StructuredData; -use TypeError; - /** * An item, such as a Thing in schema.org's vocabulary. */ -class Item +final class Item { /** * The global identifier of the item, if any. - * - * @var string|null */ - private $id; + private readonly ?string $id; /** * The types this Item implements, as URLs. * - * @var array + * @var string[] */ - private $types; + private readonly array $types; /** * The properties, as a map of property name to list of values. * * @var array> */ - private $properties = []; + private array $properties = []; /** * Item constructor. @@ -57,9 +53,9 @@ public function getId() : ?string /** * Returns the list of types this Item implements. * - * Each type is a represented as a URL, e.g. http://schema.org/Product . + * Each type is represented as a URL, e.g. http://schema.org/Product . * - * @return array + * @return string[] */ public function getTypes() : array { @@ -100,12 +96,8 @@ public function getProperty(string $name) : array * * @return void */ - public function addProperty(string $name, $value) : void + public function addProperty(string $name, Item|string $value) : void { - if (! $value instanceof Item && ! is_string($value)) { - throw new TypeError(sprintf('Property value must be an instance of %s or a string.', Item::class)); - } - $this->properties[$name][] = $value; } } diff --git a/src/JsonLdWriter.php b/src/JsonLdWriter.php index 2cdfe87..50b6389 100644 --- a/src/JsonLdWriter.php +++ b/src/JsonLdWriter.php @@ -7,7 +7,7 @@ /** * Exports Items to JSON-LD. */ -class JsonLdWriter +final class JsonLdWriter { /** * Exports a list of Items as JSON-LD. @@ -18,11 +18,12 @@ class JsonLdWriter */ public function write(Item ...$items) : string { - $items = array_map(function(Item $item) { - return $this->convertItem($item); - }, $items); + $items = array_map( + fn(Item $item) => $this->convertItem($item), + $items, + ); - return json_encode($this->extractIfSingle($items), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + return json_encode($this->extractIfSingle($items), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } /** @@ -62,7 +63,7 @@ private function convertItem(Item $item) : array * * @return mixed */ - private function extractIfSingle(array $values) + private function extractIfSingle(array $values) : mixed { if (count($values) === 1) { return $values[0]; diff --git a/src/Reader/JsonLdReader.php b/src/Reader/JsonLdReader.php index e0e42ef..f949126 100644 --- a/src/Reader/JsonLdReader.php +++ b/src/Reader/JsonLdReader.php @@ -7,6 +7,7 @@ use Brick\StructuredData\Item; use Brick\StructuredData\Reader; +use Override; use stdClass; use DOMDocument; @@ -29,12 +30,12 @@ * * https://json-ld.org/spec/latest/json-ld/ */ -class JsonLdReader implements Reader +final class JsonLdReader implements Reader { /** * @var string[] */ - private $iriProperties; + private readonly array $iriProperties; /** * JsonLdReader constructor. @@ -51,9 +52,7 @@ public function __construct(array $iriProperties = []) $this->iriProperties = $iriProperties; } - /** - * @inheritDoc - */ + #[Override] public function read(DOMDocument $document, string $url) : array { $xpath = new DOMXPath($document); @@ -65,9 +64,10 @@ public function read(DOMDocument $document, string $url) : array return []; } - $items = array_map(function(DOMNode $node) use ($url) { - return $this->readJson($node->textContent, $url); - }, $nodes); + $items = array_map( + fn(DOMNode $node) => $this->readJson($node->textContent, $url), + $nodes, + ); return array_merge(...$items); } @@ -84,7 +84,7 @@ public function read(DOMDocument $document, string $url) : array */ private function readJson(string $json, string $url) : array { - $data = json_decode($json); + $data = json_decode($json, flags: JSON_THROW_ON_ERROR); if ($data === null) { return []; @@ -101,9 +101,10 @@ private function readJson(string $json, string $url) : array } if (is_array($data)) { - $items = array_map(function($item) use ($url) { - return is_object($item) ? $this->readItem($item, $url, null) : null; - }, $data); + $items = array_map( + fn($item) => is_object($item) ? $this->readItem($item, $url, null) : null, + $data, + ); $items = array_filter($items); $items = array_values($items); @@ -134,7 +135,7 @@ private function readItem(stdClass $item, string $url, ?string $vocabulary) : It if (isset($item->{'@id'}) && is_string($item->{'@id'})) { try { $id = resolve($url, $item->{'@id'}); // always relative to the document URL, no support for @base - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { // ignore } } @@ -148,9 +149,10 @@ private function readItem(stdClass $item, string $url, ?string $vocabulary) : It $type = $this->resolveTerm($type, $vocabulary); $types = [$type]; } elseif (is_array($type)) { - $types = array_map(function($type) use ($vocabulary) { - return is_string($type) ? $this->resolveTerm($type, $vocabulary) : null; - }, $types); + $types = array_map( + fn($type) => is_string($type) ? $this->resolveTerm($type, $vocabulary) : null, + $types, + ); $types = array_filter($types); $types = array_values($types); @@ -230,17 +232,17 @@ private function resolveTerm(string $term, ?string $vocabulary) * @param string $name The property name. * @param mixed $value The property value. Any JSON type. * @param string $url The URL the document was retrieved from, for relative URL resolution. - * @param string|null $vocabulary The currently vocabulary URL, if any. + * @param string|null $vocabulary The current vocabulary URL, if any. * * @return Item|string|null The value, or NULL if the input value is NULL or an array. */ - private function getPropertyValue(string $name, $value, string $url, ?string $vocabulary) + private function getPropertyValue(string $name, mixed $value, string $url, ?string $vocabulary) : Item|string|null { if (is_string($value)) { if (in_array($name, $this->iriProperties, true)) { try { $value = resolve($url, $value); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { // ignore } } @@ -274,7 +276,7 @@ private function checkVocabularyUrl(string $url) : ?string { try { $parts = parse($url); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return null; } diff --git a/src/Reader/MicrodataReader.php b/src/Reader/MicrodataReader.php index 1f1e692..6c7c68e 100644 --- a/src/Reader/MicrodataReader.php +++ b/src/Reader/MicrodataReader.php @@ -11,6 +11,7 @@ use DOMNode; use DOMXPath; +use Override; use Sabre\Uri\InvalidUriException; use function Sabre\Uri\resolve; @@ -21,11 +22,9 @@ * * @todo support for the itemref attribute */ -class MicrodataReader implements Reader +final class MicrodataReader implements Reader { - /** - * @inheritDoc - */ + #[Override] public function read(DOMDocument $document, string $url) : array { $xpath = new DOMXPath($document); @@ -38,9 +37,10 @@ public function read(DOMDocument $document, string $url) : array $nodes = $xpath->query('//*[@itemscope and not(@itemprop)]'); $nodes = iterator_to_array($nodes); - return array_map(function(DOMNode $node) use ($xpath, $url) { - return $this->nodeToItem($node, $xpath, $url); - }, $nodes); + return array_map( + fn(DOMNode $node) => $this->nodeToItem($node, $xpath, $url), + $nodes, + ); } /** @@ -84,9 +84,7 @@ private function nodeToItem(DOMNode $node, DOMXPath $xpath, string $url) : Item * If the itemtype attribute is missing or parsing it in this way finds no tokens, the item is said to have * no item types. */ - $types = array_values(array_filter($types, function(string $type) { - return $type !== ''; - })); + $types = array_values(array_filter($types, fn(string $type) => $type !== '')); } else { $types = []; } @@ -111,9 +109,6 @@ private function nodeToItem(DOMNode $node, DOMXPath $xpath, string $url) : Item return false; } } - - // Unreachable, but makes static analysis happy - return false; }); $vocabularyIdentifier = $this->getVocabularyIdentifier($types); @@ -139,7 +134,7 @@ private function nodeToItem(DOMNode $node, DOMXPath $xpath, string $url) : Item * We therefore consider anything containing these characters as an absolute URL, and only prepend the * vocabulary identifier if none of these characters are found. */ - if (strpos($name, '.') === false && strpos($name, ':') === false) { + if (!str_contains($name, '.') && !str_contains($name, ':')) { $name = $vocabularyIdentifier . $name; } @@ -158,10 +153,8 @@ private function nodeToItem(DOMNode $node, DOMXPath $xpath, string $url) : Item * @param DOMNode $node A DOMNode representing an element with the itemprop attribute. * @param DOMXPath $xpath A DOMXPath object created from the node's document element. * @param string $url The URL the document was retrieved from, for relative URL resolution. - * - * @return Item|string */ - private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url) + private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url) : Item|string { /** * If the element also has an itemscope attribute: the value is the item created by the element. @@ -194,7 +187,7 @@ private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url) if ($attr !== null) { try { return resolve($url, $attr->textContent); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return ''; } } @@ -213,7 +206,7 @@ private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url) if ($attr !== null) { try { return resolve($url, $attr->textContent); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return ''; } } @@ -230,7 +223,7 @@ private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url) if ($attr !== null) { try { return resolve($url, $attr->textContent); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return ''; } } diff --git a/src/Reader/RdfaLiteReader.php b/src/Reader/RdfaLiteReader.php index ffde4ac..0e2ed23 100644 --- a/src/Reader/RdfaLiteReader.php +++ b/src/Reader/RdfaLiteReader.php @@ -11,6 +11,7 @@ use DOMNode; use DOMXPath; +use Override; use Sabre\Uri\InvalidUriException; use function Sabre\Uri\resolve; use function Sabre\Uri\parse; @@ -23,7 +24,7 @@ * * @todo support for the prefix attribute; only predefined prefixes are supported right now */ -class RdfaLiteReader implements Reader +final class RdfaLiteReader implements Reader { /** * The predefined RDFa prefixes. @@ -84,22 +85,21 @@ class RdfaLiteReader implements Reader 'xsd' => 'http://www.w3.org/2001/XMLSchema#', ]; - /** - * @inheritDoc - */ + #[Override] public function read(DOMDocument $document, string $url) : array { $xpath = new DOMXPath($document); /** - * Top-level item have a typeof attribute and no property attribute. + * Top-level item has a typeof attribute and no property attribute. */ $nodes = $xpath->query('//*[@typeof and not(@property)]'); $nodes = iterator_to_array($nodes); - return array_map(function(DOMNode $node) use ($xpath, $url) { - return $this->nodeToItem($node, $xpath, $url, self::PREDEFINED_PREFIXES, null); - }, $nodes); + return array_map( + fn(DOMNode $node) => $this->nodeToItem($node, $xpath, $url, self::PREDEFINED_PREFIXES, null), + $nodes, + ); } /** @@ -119,7 +119,7 @@ private function nodeToItem(DOMNode $node, DOMXPath $xpath, string $url, array $ $vocabulary = $this->updateVocabulary($node, $vocabulary); /** - * The resource attribute holds the item identifier, than must be resolved relative to the current URL. + * The resource attribute holds the item identifier, that must be resolved relative to the current URL. * * https://www.w3.org/TR/rdfa-lite/#resource */ @@ -150,9 +150,7 @@ private function nodeToItem(DOMNode $node, DOMXPath $xpath, string $url, array $ }, $types); // Remove empty values - $types = array_values(array_filter($types, function(string $type) { - return $type !== ''; - })); + $types = array_values(array_filter($types, fn(string $type) => $type !== '')); $item = new Item($id, ...$types); @@ -245,7 +243,7 @@ private function isValidAbsoluteURL(string $url) : bool { try { $parts = parse($url); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return false; } @@ -292,7 +290,7 @@ private function checkVocabularyUrl(string $url) : ?string { try { $parts = parse($url); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return null; } @@ -319,10 +317,8 @@ private function checkVocabularyUrl(string $url) : ?string * @param string $url The URL the document was retrieved from, for relative URL resolution. * @param string[] $prefixes The prefixes in use, as a map of prefix to vocabulary URL. * @param string|null $vocabulary The URL of the vocabulary in use, if any. - * - * @return Item|string */ - private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url, array $prefixes, ?string $vocabulary) + private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url, array $prefixes, ?string $vocabulary) : Item|string { // If the element also has an typeof attribute, create an item from the element $attr = $node->attributes->getNamedItem('typeof'); @@ -344,7 +340,7 @@ private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url, a if ($attr !== null) { try { return resolve($url, $attr->textContent); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return ''; } } @@ -355,7 +351,7 @@ private function getPropertyValue(DOMNode $node, DOMXPath $xpath, string $url, a if ($attr !== null) { try { return resolve($url, $attr->textContent); - } catch (InvalidUriException $e) { + } catch (InvalidUriException) { return ''; } } diff --git a/src/Reader/ReaderChain.php b/src/Reader/ReaderChain.php index 08c7e1e..0d684bb 100644 --- a/src/Reader/ReaderChain.php +++ b/src/Reader/ReaderChain.php @@ -7,16 +7,17 @@ use Brick\StructuredData\Reader; use DOMDocument; +use Override; /** * Chains several schema readers and returns the aggregate results. */ -class ReaderChain implements Reader +final class ReaderChain implements Reader { /** * @var Reader[] */ - private $readers; + private readonly array $readers; /** * ReaderChain constructor. @@ -28,9 +29,7 @@ public function __construct(Reader ...$readers) $this->readers = $readers; } - /** - * @inheritDoc - */ + #[Override] public function read(DOMDocument $document, string $url) : array { if (! $this->readers) {