From 0cadf929aba826f3e8080156c8eee7ff5737300b Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Tue, 6 Jan 2026 01:13:27 +0100 Subject: [PATCH 01/45] [DRAFT]: v3 --- .github/workflows/main.yml | 4 +- .gitignore | 3 +- composer.json | 4 +- phpunit.xml | 35 ++- src/Exceptions/FeedException.php | 11 + src/Exceptions/SeoException.php | 2 +- src/Exceptions/SitemapException.php | 2 +- src/Indexing/BingIndexer.php | 0 src/Indexing/GoogleIndexer.php | 0 .../IndexNowIndexer.php} | 6 +- src/{Ping.php => Indexing/SitemapPinger.php} | 4 +- src/Interfaces/SchemaInterface.php | 2 +- src/Interfaces/SeoInterface.php | 2 +- src/Interfaces/SitemapBuilderInterface.php | 2 +- src/Interfaces/SitemapIndexInterface.php | 2 +- src/Interfaces/SitemapInterface.php | 2 +- src/MetaTags.php | 9 +- src/Robots.php | 2 +- src/Schema.php | 2 +- src/Schema/CreativeWork.php | 13 + src/Schema/CreativeWork/Answer.php | 17 ++ src/Schema/CreativeWork/Article.php | 17 ++ src/Schema/CreativeWork/BlogPosting.php | 16 ++ src/Schema/CreativeWork/Course.php | 17 ++ src/Schema/CreativeWork/Dataset.php | 17 ++ src/Schema/CreativeWork/FAQPage.php | 16 ++ src/Schema/CreativeWork/HowTo.php | 17 ++ src/Schema/CreativeWork/HowToSection.php | 16 ++ src/Schema/CreativeWork/HowToStep.php | 16 ++ src/Schema/CreativeWork/ImageObject.php | 16 ++ src/Schema/CreativeWork/MediaObject.php | 17 ++ src/Schema/CreativeWork/NewsArticle.php | 16 ++ src/Schema/CreativeWork/ProfilePage.php | 16 ++ src/Schema/CreativeWork/QAPage.php | 16 ++ src/Schema/CreativeWork/Question.php | 17 ++ src/Schema/CreativeWork/Recipe.php | 16 ++ src/Schema/CreativeWork/Review.php | 17 ++ .../CreativeWork/SoftwareApplication.php | 17 ++ src/Schema/CreativeWork/VideoObject.php | 16 ++ src/Schema/CreativeWork/WebPage.php | 17 ++ src/Schema/Event.php | 19 ++ src/Schema/Intangible.php | 13 + src/Schema/Intangible/AggregateOffer.php | 16 ++ src/Schema/Intangible/AggregateRating.php | 16 ++ src/Schema/Intangible/Brand.php | 17 ++ src/Schema/Intangible/BreadcrumbList.php | 16 ++ src/Schema/Intangible/ContactPoint.php | 17 ++ .../Intangible/EmployerAggregateRating.php | 16 ++ src/Schema/Intangible/GeoCoordinates.php | 17 ++ src/Schema/Intangible/ItemList.php | 17 ++ src/Schema/Intangible/JobPosting.php | 17 ++ src/Schema/Intangible/ListItem.php | 17 ++ src/Schema/Intangible/MonetaryAmount.php | 17 ++ src/Schema/Intangible/Offer.php | 17 ++ .../Intangible/OpeningHoursSpecification.php | 17 ++ src/Schema/Intangible/PostalAddress.php | 16 ++ src/Schema/Intangible/Rating.php | 17 ++ src/Schema/Intangible/VirtualLocation.php | 17 ++ src/Schema/Organization.php | 19 ++ src/Schema/Person.php | 19 ++ src/Schema/Place.php | 19 ++ src/Schema/Place/Hotel.php | 17 ++ src/Schema/Place/LocalBusiness.php | 17 ++ src/Schema/Place/Restaurant.php | 17 ++ src/Schema/Product.php | 19 ++ src/Schema/Thing.php | 26 +- src/Schema/Things/ContactPoint.php | 25 -- src/Schema/Things/Offer.php | 37 --- src/Schema/Things/Organization.php | 31 --- src/Schema/Things/Product.php | 43 ---- src/Schema/Things/WebPage.php | 31 --- src/Sitemap.php | 2 +- src/Sitemap/LinksBuilder.php | 2 +- src/Sitemap/NewsBuilder.php | 2 +- src/Sitemap/SitemapBuilder.php | 8 +- src/Sitemap/SitemapIndex.php | 2 +- src/{Helper.php => Utils/Utils.php} | 6 +- src/Validation/SchemaRules/AggregateOffer.php | 14 ++ .../SchemaRules/AggregateRating.php | 19 ++ src/Validation/SchemaRules/Answer.php | 11 + src/Validation/SchemaRules/Article.php | 16 ++ src/Validation/SchemaRules/BlogPosting.php | 3 + src/Validation/SchemaRules/Brand.php | 7 + src/Validation/SchemaRules/BreadcrumbList.php | 9 + src/Validation/SchemaRules/ContactPoint.php | 7 + src/Validation/SchemaRules/Course.php | 7 + src/Validation/SchemaRules/Dataset.php | 27 ++ .../SchemaRules/EmployerAggregateRating.php | 22 ++ src/Validation/SchemaRules/Event.php | 36 +++ src/Validation/SchemaRules/FAQPage.php | 9 + src/Validation/SchemaRules/GeoCoordinates.php | 7 + src/Validation/SchemaRules/Hotel.php | 19 ++ src/Validation/SchemaRules/HowTo.php | 19 ++ src/Validation/SchemaRules/HowToSection.php | 13 + src/Validation/SchemaRules/HowToStep.php | 14 ++ src/Validation/SchemaRules/ImageObject.php | 16 ++ src/Validation/SchemaRules/ItemList.php | 9 + src/Validation/SchemaRules/JobPosting.php | 38 +++ src/Validation/SchemaRules/ListItem.php | 16 ++ src/Validation/SchemaRules/LocalBusiness.php | 33 +++ src/Validation/SchemaRules/MediaObject.php | 9 + src/Validation/SchemaRules/MonetaryAmount.php | 8 + src/Validation/SchemaRules/NewsArticle.php | 3 + src/Validation/SchemaRules/Offer.php | 10 + .../SchemaRules/OpeningHoursSpecification.php | 12 + src/Validation/SchemaRules/Organization.php | 22 ++ src/Validation/SchemaRules/Person.php | 16 ++ src/Validation/SchemaRules/PlaceRule.php | 12 + src/Validation/SchemaRules/PostalAddress.php | 9 + src/Validation/SchemaRules/Product.php | 27 ++ src/Validation/SchemaRules/ProfilePage.php | 10 + src/Validation/SchemaRules/QAPage.php | 8 + src/Validation/SchemaRules/Question.php | 23 ++ src/Validation/SchemaRules/Rating.php | 10 + src/Validation/SchemaRules/Recipe.php | 32 +++ src/Validation/SchemaRules/Restaurant.php | 18 ++ src/Validation/SchemaRules/Review.php | 17 ++ .../SchemaRules/SoftwareApplication.php | 22 ++ src/Validation/SchemaRules/Store.php | 12 + src/Validation/SchemaRules/VideoObject.php | 28 +++ .../SchemaRules/VirtualLocation.php | 5 + src/Validation/SchemaRules/WebPage.php | 10 + src/Validation/SchemaValidator.php | 235 ++++++++++++++++++ tests/MetaTagsTest.php | 1 + tests/RobotsTest.php | 1 + tests/SchemaObjectsTest.php | 24 -- tests/SchemaTest.php | 54 ---- tests/SchemaValidationTest.php | 85 +++++++ tests/SitemapsTest.php | 1 + tests/bootstrap.php | 5 - 130 files changed, 1861 insertions(+), 317 deletions(-) create mode 100644 src/Exceptions/FeedException.php create mode 100644 src/Indexing/BingIndexer.php create mode 100644 src/Indexing/GoogleIndexer.php rename src/{Indexing.php => Indexing/IndexNowIndexer.php} (93%) rename src/{Ping.php => Indexing/SitemapPinger.php} (94%) create mode 100644 src/Schema/CreativeWork.php create mode 100644 src/Schema/CreativeWork/Answer.php create mode 100644 src/Schema/CreativeWork/Article.php create mode 100644 src/Schema/CreativeWork/BlogPosting.php create mode 100644 src/Schema/CreativeWork/Course.php create mode 100644 src/Schema/CreativeWork/Dataset.php create mode 100644 src/Schema/CreativeWork/FAQPage.php create mode 100644 src/Schema/CreativeWork/HowTo.php create mode 100644 src/Schema/CreativeWork/HowToSection.php create mode 100644 src/Schema/CreativeWork/HowToStep.php create mode 100644 src/Schema/CreativeWork/ImageObject.php create mode 100644 src/Schema/CreativeWork/MediaObject.php create mode 100644 src/Schema/CreativeWork/NewsArticle.php create mode 100644 src/Schema/CreativeWork/ProfilePage.php create mode 100644 src/Schema/CreativeWork/QAPage.php create mode 100644 src/Schema/CreativeWork/Question.php create mode 100644 src/Schema/CreativeWork/Recipe.php create mode 100644 src/Schema/CreativeWork/Review.php create mode 100644 src/Schema/CreativeWork/SoftwareApplication.php create mode 100644 src/Schema/CreativeWork/VideoObject.php create mode 100644 src/Schema/CreativeWork/WebPage.php create mode 100644 src/Schema/Event.php create mode 100644 src/Schema/Intangible.php create mode 100644 src/Schema/Intangible/AggregateOffer.php create mode 100644 src/Schema/Intangible/AggregateRating.php create mode 100644 src/Schema/Intangible/Brand.php create mode 100644 src/Schema/Intangible/BreadcrumbList.php create mode 100644 src/Schema/Intangible/ContactPoint.php create mode 100644 src/Schema/Intangible/EmployerAggregateRating.php create mode 100644 src/Schema/Intangible/GeoCoordinates.php create mode 100644 src/Schema/Intangible/ItemList.php create mode 100644 src/Schema/Intangible/JobPosting.php create mode 100644 src/Schema/Intangible/ListItem.php create mode 100644 src/Schema/Intangible/MonetaryAmount.php create mode 100644 src/Schema/Intangible/Offer.php create mode 100644 src/Schema/Intangible/OpeningHoursSpecification.php create mode 100644 src/Schema/Intangible/PostalAddress.php create mode 100644 src/Schema/Intangible/Rating.php create mode 100644 src/Schema/Intangible/VirtualLocation.php create mode 100644 src/Schema/Organization.php create mode 100644 src/Schema/Person.php create mode 100644 src/Schema/Place.php create mode 100644 src/Schema/Place/Hotel.php create mode 100644 src/Schema/Place/LocalBusiness.php create mode 100644 src/Schema/Place/Restaurant.php create mode 100644 src/Schema/Product.php delete mode 100644 src/Schema/Things/ContactPoint.php delete mode 100644 src/Schema/Things/Offer.php delete mode 100644 src/Schema/Things/Organization.php delete mode 100644 src/Schema/Things/Product.php delete mode 100644 src/Schema/Things/WebPage.php rename src/{Helper.php => Utils/Utils.php} (91%) create mode 100644 src/Validation/SchemaRules/AggregateOffer.php create mode 100644 src/Validation/SchemaRules/AggregateRating.php create mode 100644 src/Validation/SchemaRules/Answer.php create mode 100644 src/Validation/SchemaRules/Article.php create mode 100644 src/Validation/SchemaRules/BlogPosting.php create mode 100644 src/Validation/SchemaRules/Brand.php create mode 100644 src/Validation/SchemaRules/BreadcrumbList.php create mode 100644 src/Validation/SchemaRules/ContactPoint.php create mode 100644 src/Validation/SchemaRules/Course.php create mode 100644 src/Validation/SchemaRules/Dataset.php create mode 100644 src/Validation/SchemaRules/EmployerAggregateRating.php create mode 100644 src/Validation/SchemaRules/Event.php create mode 100644 src/Validation/SchemaRules/FAQPage.php create mode 100644 src/Validation/SchemaRules/GeoCoordinates.php create mode 100644 src/Validation/SchemaRules/Hotel.php create mode 100644 src/Validation/SchemaRules/HowTo.php create mode 100644 src/Validation/SchemaRules/HowToSection.php create mode 100644 src/Validation/SchemaRules/HowToStep.php create mode 100644 src/Validation/SchemaRules/ImageObject.php create mode 100644 src/Validation/SchemaRules/ItemList.php create mode 100644 src/Validation/SchemaRules/JobPosting.php create mode 100644 src/Validation/SchemaRules/ListItem.php create mode 100644 src/Validation/SchemaRules/LocalBusiness.php create mode 100644 src/Validation/SchemaRules/MediaObject.php create mode 100644 src/Validation/SchemaRules/MonetaryAmount.php create mode 100644 src/Validation/SchemaRules/NewsArticle.php create mode 100644 src/Validation/SchemaRules/Offer.php create mode 100644 src/Validation/SchemaRules/OpeningHoursSpecification.php create mode 100644 src/Validation/SchemaRules/Organization.php create mode 100644 src/Validation/SchemaRules/Person.php create mode 100644 src/Validation/SchemaRules/PlaceRule.php create mode 100644 src/Validation/SchemaRules/PostalAddress.php create mode 100644 src/Validation/SchemaRules/Product.php create mode 100644 src/Validation/SchemaRules/ProfilePage.php create mode 100644 src/Validation/SchemaRules/QAPage.php create mode 100644 src/Validation/SchemaRules/Question.php create mode 100644 src/Validation/SchemaRules/Rating.php create mode 100644 src/Validation/SchemaRules/Recipe.php create mode 100644 src/Validation/SchemaRules/Restaurant.php create mode 100644 src/Validation/SchemaRules/Review.php create mode 100644 src/Validation/SchemaRules/SoftwareApplication.php create mode 100644 src/Validation/SchemaRules/Store.php create mode 100644 src/Validation/SchemaRules/VideoObject.php create mode 100644 src/Validation/SchemaRules/VirtualLocation.php create mode 100644 src/Validation/SchemaRules/WebPage.php create mode 100644 src/Validation/SchemaValidator.php delete mode 100644 tests/SchemaObjectsTest.php delete mode 100644 tests/SchemaTest.php create mode 100644 tests/SchemaValidationTest.php delete mode 100644 tests/bootstrap.php diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f668d39..eaed91a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} strategy: - max-parallel: 2 + max-parallel: 3 matrix: os: [ubuntu-latest] - php: [7.2, 7.4, 8.0, 8.1, 8.2, 8.3] + php: [8.1, 8.2, 8.3] experimental: [false] name: PHP ${{ matrix.php }} test on ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index cf6509b..a603ced 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor /composer.lock -/.idea \ No newline at end of file +/.idea +/.phpunit.cache diff --git a/composer.json b/composer.json index 639af56..7f567de 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ } ], "require": { - "php": ">=7.2", + "php": ">=8.1", "ext-xml": "*", "ext-curl": "*", "ext-json": "*" @@ -40,7 +40,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^8.5" + "phpunit/phpunit": "^10.0" }, "minimum-stability": "dev", "prefer-stable": true diff --git a/phpunit.xml b/phpunit.xml index c08c88c..a69b6a4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,25 +1,20 @@ - - - src - - + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" + bootstrap="vendor/autoload.php" + colors="true" + cacheResult="false" + cacheDirectory=".phpunit.cache" +> - - ./tests/ - + + tests + + + + src + + diff --git a/src/Exceptions/FeedException.php b/src/Exceptions/FeedException.php new file mode 100644 index 0000000..f5d964e --- /dev/null +++ b/src/Exceptions/FeedException.php @@ -0,0 +1,11 @@ +title = Helper::escape($title); + $this->title = Utils::escape($title); return $this->meta('title', $title)->og('title', $title)->twitter('title', $title); } @@ -262,7 +263,7 @@ public function build(array $tags): string foreach ($tag[1] as $a => $v) { - $out .= $a .'="'. Helper::escape($v) .'" '; + $out .= $a .'="'. Utils::escape($v) .'" '; } $out .= "/>"; diff --git a/src/Robots.php b/src/Robots.php index d57bc63..7fd46ab 100644 --- a/src/Robots.php +++ b/src/Robots.php @@ -8,7 +8,7 @@ * @since v2.0 * @see https://git.io/phpseo * @license MIT - * @copyright 2019-present Mohamed Elabhja + * @copyright Mohamed Elabhja */ class Robots implements SeoInterface { diff --git a/src/Schema.php b/src/Schema.php index 8f211c4..9113afd 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -8,7 +8,7 @@ * @since v2.0 * @see https://git.io/phpseo * @license MIT - * @copyright 2019-present Mohamed Elabhja + * @copyright Mohamed Elabhja */ class Schema implements SchemaInterface { diff --git a/src/Schema/CreativeWork.php b/src/Schema/CreativeWork.php new file mode 100644 index 0000000..d5636f6 --- /dev/null +++ b/src/Schema/CreativeWork.php @@ -0,0 +1,13 @@ +data = $data; - $this->type = $type; + $this->type = $type; + $this->props = $props; } public function __get(string $name) { - return $this->data[$name] ?? null; + return $this->props[$name] ?? null; } - public function __set(string $name, $value) { - $this->data[$name] = $value; + $this->props[$name] = $value; } public function jsonSerialize(): array { $data = [ - '@type' => $this->type, - '@context' => $this->context ?? "https://schema.org/", + '@type' => $this->type, + '@context' => $this->context ?? "https://schema.org", ]; - return array_merge($this->data, $data); + return array_merge($this->props, $data); } public function __toString(): string diff --git a/src/Schema/Things/ContactPoint.php b/src/Schema/Things/ContactPoint.php deleted file mode 100644 index c0d684f..0000000 --- a/src/Schema/Things/ContactPoint.php +++ /dev/null @@ -1,25 +0,0 @@ -data['telephone']=$value; - return $this; - } - - public function setContactType(string $value) :self - { - $this->data['contactType']=$value; - return $this; - } -} \ No newline at end of file diff --git a/src/Schema/Things/Offer.php b/src/Schema/Things/Offer.php deleted file mode 100644 index 59b1eb0..0000000 --- a/src/Schema/Things/Offer.php +++ /dev/null @@ -1,37 +0,0 @@ -data['availability']=$value; - return $this; - } - - public function setPriceCurrency(string $value) :self - { - $this->data['priceCurrency']=$value; - return $this; - } - - public function setPrice(float $value) :self - { - $this->data['price']=$value; - return $this; - } - - public function setUrl(string $value) :self - { - $this->data['url']=$value; - return $this; - } -} \ No newline at end of file diff --git a/src/Schema/Things/Organization.php b/src/Schema/Things/Organization.php deleted file mode 100644 index 05a7ebf..0000000 --- a/src/Schema/Things/Organization.php +++ /dev/null @@ -1,31 +0,0 @@ -data['url']=$value; - return $this; - } - - public function setLogo(string $value) :self - { - $this->data['logo']=$value; - return $this; - } - - public function setContactPoint(ContactPoint $value) :self - { - $this->data['contactPoint']=$value; - return $this; - } -} \ No newline at end of file diff --git a/src/Schema/Things/Product.php b/src/Schema/Things/Product.php deleted file mode 100644 index d2660d8..0000000 --- a/src/Schema/Things/Product.php +++ /dev/null @@ -1,43 +0,0 @@ -data['name']=$value; - return $this; - } - - public function setSku(string $value) :self - { - $this->data['sku']=$value; - return $this; - } - - public function setImage(string $value) :self - { - $this->data['image']=$value; - return $this; - } - - public function setDescription(string $value) :self - { - $this->data['description']=$value; - return $this; - } - - public function setOffers(Offer $value) :self - { - $this->data['offers']=$value; - return $this; - } -} \ No newline at end of file diff --git a/src/Schema/Things/WebPage.php b/src/Schema/Things/WebPage.php deleted file mode 100644 index 2b27d61..0000000 --- a/src/Schema/Things/WebPage.php +++ /dev/null @@ -1,31 +0,0 @@ -data['@id']=$value; - return $this; - } - - public function setUrl(string $value) :self - { - $this->data['url']=$value; - return $this; - } - - public function setName(string $value) :self - { - $this->data['name']=$value; - return $this; - } -} \ No newline at end of file diff --git a/src/Sitemap.php b/src/Sitemap.php index 2dc061f..69e42ab 100644 --- a/src/Sitemap.php +++ b/src/Sitemap.php @@ -14,7 +14,7 @@ * @since v2.0 * @see https://git.io/phpseo * @license MIT - * @copyright 2019-present Mohamed Elabhja + * @copyright Mohamed Elabhja */ class Sitemap implements SitemapIndexInterface { diff --git a/src/Sitemap/LinksBuilder.php b/src/Sitemap/LinksBuilder.php index 2c13524..1cdf9b0 100644 --- a/src/Sitemap/LinksBuilder.php +++ b/src/Sitemap/LinksBuilder.php @@ -6,6 +6,6 @@ * @since v2.0 * @see https://git.io/phpseo * @license MIT - * @copyright 2019-present Mohamed Elabhja + * @copyright Mohamed Elabhja */ class LinksBuilder extends SitemapBuilder { } diff --git a/src/Sitemap/NewsBuilder.php b/src/Sitemap/NewsBuilder.php index da5d5b2..1690956 100644 --- a/src/Sitemap/NewsBuilder.php +++ b/src/Sitemap/NewsBuilder.php @@ -11,7 +11,7 @@ * @since v2.0 * @see https://git.io/phpseo * @license MIT - * @copyright 2019-present Mohamed Elabhja + * @copyright Mohamed Elabhja */ class NewsBuilder extends SitemapBuilder { diff --git a/src/Sitemap/SitemapBuilder.php b/src/Sitemap/SitemapBuilder.php index 53b1981..b9de7f9 100644 --- a/src/Sitemap/SitemapBuilder.php +++ b/src/Sitemap/SitemapBuilder.php @@ -3,7 +3,7 @@ use SimpleXMLElement; use Melbahja\Seo\{ - Helper, + Utils\Utils, Exceptions\SitemapException, Interfaces\SitemapBuilderInterface }; @@ -13,7 +13,7 @@ * @since v2.0 * @see https://git.io/phpseo * @license MIT - * @copyright 2019-present Mohamed Elabhja + * @copyright Mohamed Elabhja */ class SitemapBuilder implements SitemapBuilderInterface { @@ -124,7 +124,7 @@ public function url(string $url): SitemapBuilderInterface throw new SitemapException("The maximum urls has been exhausted"); } - $this->url['loc'] = Helper::escapeUrl($url); + $this->url['loc'] = Utils::escapeUrl($url); return $this; } @@ -141,7 +141,7 @@ public function alternate(string $path, string $lang) $path = "/{$path}"; } - $this->url['alternate'][] = [Helper::escapeUrl($this->domain . $path), $lang]; + $this->url['alternate'][] = [Utils::escapeUrl($this->domain . $path), $lang]; return $this; } diff --git a/src/Sitemap/SitemapIndex.php b/src/Sitemap/SitemapIndex.php index f802435..c6bbf0c 100644 --- a/src/Sitemap/SitemapIndex.php +++ b/src/Sitemap/SitemapIndex.php @@ -9,7 +9,7 @@ * @since v2.0 * @see https://git.io/phpseo * @license MIT - * @copyright 2019-present Mohamed Elabhja + * @copyright Mohamed Elabhja */ class SitemapIndex { diff --git a/src/Helper.php b/src/Utils/Utils.php similarity index 91% rename from src/Helper.php rename to src/Utils/Utils.php index b8e1f49..70d200a 100644 --- a/src/Helper.php +++ b/src/Utils/Utils.php @@ -1,14 +1,14 @@ [ + 'type' => 'float', + 'required' => true, + ], + 'priceCurrency' => [ + 'type' => 'string', + 'required' => true, + ], + 'highPrice' => 'float', + 'offerCount' => 'int', +]; diff --git a/src/Validation/SchemaRules/AggregateRating.php b/src/Validation/SchemaRules/AggregateRating.php new file mode 100644 index 0000000..8094265 --- /dev/null +++ b/src/Validation/SchemaRules/AggregateRating.php @@ -0,0 +1,19 @@ + [ + 'type' => 'float', + 'required' => true, + ], + 'ratingCount' => [ + 'type' => 'int', + 'required' => true, + ], + 'reviewCount' => [ + 'type' => 'int', + 'required' => true, + ], + 'itemReviewed' => '\Melbahja\Seo\Schema\Thing', + 'bestRating' => 'float', + 'worstRating' => 'float', +]; diff --git a/src/Validation/SchemaRules/Answer.php b/src/Validation/SchemaRules/Answer.php new file mode 100644 index 0000000..51a1648 --- /dev/null +++ b/src/Validation/SchemaRules/Answer.php @@ -0,0 +1,11 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'datePublished' => 'iso_date', + 'author' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'upvoteCount' => 'int', +]; diff --git a/src/Validation/SchemaRules/Article.php b/src/Validation/SchemaRules/Article.php new file mode 100644 index 0000000..08562c2 --- /dev/null +++ b/src/Validation/SchemaRules/Article.php @@ -0,0 +1,16 @@ + 'string', + 'image' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'datePublished' => 'iso_date', + 'dateModified' => 'iso_date', + 'author' => [ + 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization|array', + 'item_type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + ], + 'publisher' => '\Melbahja\Seo\Schema\Organization', +]; diff --git a/src/Validation/SchemaRules/BlogPosting.php b/src/Validation/SchemaRules/BlogPosting.php new file mode 100644 index 0000000..7f1beee --- /dev/null +++ b/src/Validation/SchemaRules/BlogPosting.php @@ -0,0 +1,3 @@ + 'string', + 'logo' => 'string', + 'url' => 'url', +]; diff --git a/src/Validation/SchemaRules/BreadcrumbList.php b/src/Validation/SchemaRules/BreadcrumbList.php new file mode 100644 index 0000000..d08a073 --- /dev/null +++ b/src/Validation/SchemaRules/BreadcrumbList.php @@ -0,0 +1,9 @@ + [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\Intangible\ListItem', + 'required' => true, + ], +]; diff --git a/src/Validation/SchemaRules/ContactPoint.php b/src/Validation/SchemaRules/ContactPoint.php new file mode 100644 index 0000000..3796557 --- /dev/null +++ b/src/Validation/SchemaRules/ContactPoint.php @@ -0,0 +1,7 @@ + 'string', + 'email' => 'email', + 'contactType' => 'string', +]; diff --git a/src/Validation/SchemaRules/Course.php b/src/Validation/SchemaRules/Course.php new file mode 100644 index 0000000..f2a45ab --- /dev/null +++ b/src/Validation/SchemaRules/Course.php @@ -0,0 +1,7 @@ + 'string', + 'description' => 'string', + 'provider' => '\Melbahja\Seo\Schema\Organization|\Melbahja\Seo\Schema\Person', +]; diff --git a/src/Validation/SchemaRules/Dataset.php b/src/Validation/SchemaRules/Dataset.php new file mode 100644 index 0000000..b4dbd06 --- /dev/null +++ b/src/Validation/SchemaRules/Dataset.php @@ -0,0 +1,27 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'description' => [ + 'type' => 'string', + 'required' => true, + ], + 'creator' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'license' => 'string', + 'url' => 'url', + 'identifier' => 'string', + 'keywords' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'variableMeasured' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'temporalCoverage' => 'string', + 'spatialCoverage' => '\Melbahja\Seo\Schema\Place|string', + 'version' => 'string', +]; diff --git a/src/Validation/SchemaRules/EmployerAggregateRating.php b/src/Validation/SchemaRules/EmployerAggregateRating.php new file mode 100644 index 0000000..2eaa575 --- /dev/null +++ b/src/Validation/SchemaRules/EmployerAggregateRating.php @@ -0,0 +1,22 @@ + [ + 'type' => '\Melbahja\Seo\Schema\Organization', + 'required' => true, + ], + 'ratingValue' => [ + 'type' => 'float', + 'required' => true, + ], + 'ratingCount' => [ + 'type' => 'int', + 'required' => true, + ], + 'reviewCount' => [ + 'type' => 'int', + 'required' => true, + ], + 'bestRating' => 'float', + 'worstRating' => 'float', +]; diff --git a/src/Validation/SchemaRules/Event.php b/src/Validation/SchemaRules/Event.php new file mode 100644 index 0000000..0e7741f --- /dev/null +++ b/src/Validation/SchemaRules/Event.php @@ -0,0 +1,36 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'startDate' => [ + 'type' => 'iso_date', + 'required' => true, + ], + 'location' => [ + 'type' => '\Melbahja\Seo\Schema\Place|\Melbahja\Seo\Schema\Intangible\VirtualLocation', + 'required' => true, + ], + 'endDate' => 'iso_date', + 'eventStatus' => 'string', + 'eventAttendanceMode' => 'string', + 'description' => 'string', + 'offers' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\Offer|array', + 'item_type' => '\Melbahja\Seo\Schema\Intangible\Offer', + ], + 'performer' => [ + 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization|array', + 'item_type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + ], + 'organizer' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'image' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], +]; diff --git a/src/Validation/SchemaRules/FAQPage.php b/src/Validation/SchemaRules/FAQPage.php new file mode 100644 index 0000000..0a99c09 --- /dev/null +++ b/src/Validation/SchemaRules/FAQPage.php @@ -0,0 +1,9 @@ + [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Question', + 'required' => true, + ], +]; diff --git a/src/Validation/SchemaRules/GeoCoordinates.php b/src/Validation/SchemaRules/GeoCoordinates.php new file mode 100644 index 0000000..192e9aa --- /dev/null +++ b/src/Validation/SchemaRules/GeoCoordinates.php @@ -0,0 +1,7 @@ + 'float', + 'longitude' => 'float', +]; diff --git a/src/Validation/SchemaRules/Hotel.php b/src/Validation/SchemaRules/Hotel.php new file mode 100644 index 0000000..5720fe3 --- /dev/null +++ b/src/Validation/SchemaRules/Hotel.php @@ -0,0 +1,19 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'address' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'required' => true, + ], + 'amenityFeature' => [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\Intangible\LocationFeatureSpecification', + ], + 'checkinTime' => 'string', + 'checkoutTime' => 'string', +]; \ No newline at end of file diff --git a/src/Validation/SchemaRules/HowTo.php b/src/Validation/SchemaRules/HowTo.php new file mode 100644 index 0000000..9f701b0 --- /dev/null +++ b/src/Validation/SchemaRules/HowTo.php @@ -0,0 +1,19 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'step' => [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\HowToStep|\Melbahja\Seo\Schema\CreativeWork\HowToSection', + 'required' => true, + ], + 'image' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'totalTime' => 'string', + 'description' => 'string', +]; diff --git a/src/Validation/SchemaRules/HowToSection.php b/src/Validation/SchemaRules/HowToSection.php new file mode 100644 index 0000000..a5421be --- /dev/null +++ b/src/Validation/SchemaRules/HowToSection.php @@ -0,0 +1,13 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'itemListElement' => [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\HowToStep|\Melbahja\Seo\Schema\CreativeWork\HowToSection', + 'required' => true, + ], +]; diff --git a/src/Validation/SchemaRules/HowToStep.php b/src/Validation/SchemaRules/HowToStep.php new file mode 100644 index 0000000..b21e98e --- /dev/null +++ b/src/Validation/SchemaRules/HowToStep.php @@ -0,0 +1,14 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'name' => 'string', + 'url' => 'url', + 'image' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], +]; diff --git a/src/Validation/SchemaRules/ImageObject.php b/src/Validation/SchemaRules/ImageObject.php new file mode 100644 index 0000000..a37e185 --- /dev/null +++ b/src/Validation/SchemaRules/ImageObject.php @@ -0,0 +1,16 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'license' => 'url', + 'acquireLicensePage' => 'url', + 'creator' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'creditText' => 'string', + 'copyrightNotice' => 'string', + 'name' => 'string', +]; diff --git a/src/Validation/SchemaRules/ItemList.php b/src/Validation/SchemaRules/ItemList.php new file mode 100644 index 0000000..d08a073 --- /dev/null +++ b/src/Validation/SchemaRules/ItemList.php @@ -0,0 +1,9 @@ + [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\Intangible\ListItem', + 'required' => true, + ], +]; diff --git a/src/Validation/SchemaRules/JobPosting.php b/src/Validation/SchemaRules/JobPosting.php new file mode 100644 index 0000000..637718a --- /dev/null +++ b/src/Validation/SchemaRules/JobPosting.php @@ -0,0 +1,38 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'description' => [ + 'type' => 'string', + 'required' => true, + ], + 'datePosted' => [ + 'type' => 'iso_date', + 'required' => true, + ], + 'hiringOrganization' => [ + 'type' => '\Melbahja\Seo\Schema\Organization', + 'required' => true, + ], + 'jobLocation' => [ + 'type' => '\Melbahja\Seo\Schema\Place|array', + 'item_type' => '\Melbahja\Seo\Schema\Place', + 'required' => true, + ], + 'employmentType' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'validThrough' => 'iso_date', + 'baseSalary' => '\Melbahja\Seo\Schema\Intangible\MonetaryAmount', + 'applicantLocationRequirements' => [ + 'type' => '\Melbahja\Seo\Schema\Place\Country|array', + 'item_type' => '\Melbahja\Seo\Schema\Place\Country', + ], + 'jobLocationType' => 'string', + 'directApply' => 'bool', +]; diff --git a/src/Validation/SchemaRules/ListItem.php b/src/Validation/SchemaRules/ListItem.php new file mode 100644 index 0000000..4051bcb --- /dev/null +++ b/src/Validation/SchemaRules/ListItem.php @@ -0,0 +1,16 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'item' => [ + 'type' => 'string', + 'required' => true, + ], + 'position' => [ + 'type' => 'int', + 'required' => true, + ], +]; diff --git a/src/Validation/SchemaRules/LocalBusiness.php b/src/Validation/SchemaRules/LocalBusiness.php new file mode 100644 index 0000000..c6136b8 --- /dev/null +++ b/src/Validation/SchemaRules/LocalBusiness.php @@ -0,0 +1,33 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'address' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'required' => true, + ], + 'url' => 'url', + 'telephone' => 'string', + 'priceRange' => 'string', + 'openingHoursSpecification' => [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\Intangible\OpeningHoursSpecification', + ], + 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', + 'review' => [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Review', + ], + 'geo' => '\Melbahja\Seo\Schema\Intangible\GeoCoordinates', + 'servesCuisine' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], +]; diff --git a/src/Validation/SchemaRules/MediaObject.php b/src/Validation/SchemaRules/MediaObject.php new file mode 100644 index 0000000..f57ec1e --- /dev/null +++ b/src/Validation/SchemaRules/MediaObject.php @@ -0,0 +1,9 @@ + 'url', + 'duration' => 'string', + 'encodingFormat' => 'string', + 'height' => 'int', + 'width' => 'int', +]; diff --git a/src/Validation/SchemaRules/MonetaryAmount.php b/src/Validation/SchemaRules/MonetaryAmount.php new file mode 100644 index 0000000..86b8a1c --- /dev/null +++ b/src/Validation/SchemaRules/MonetaryAmount.php @@ -0,0 +1,8 @@ + 'string', + 'value' => 'float|int', + 'minValue' => 'float|int', + 'maxValue' => 'float|int', +]; diff --git a/src/Validation/SchemaRules/NewsArticle.php b/src/Validation/SchemaRules/NewsArticle.php new file mode 100644 index 0000000..7f1beee --- /dev/null +++ b/src/Validation/SchemaRules/NewsArticle.php @@ -0,0 +1,3 @@ + 'float|string', + 'priceCurrency' => 'string', + 'availability' => 'string', + 'priceValidUntil' => 'iso_date', + 'url' => 'url', + 'itemCondition' => 'string', +]; diff --git a/src/Validation/SchemaRules/OpeningHoursSpecification.php b/src/Validation/SchemaRules/OpeningHoursSpecification.php new file mode 100644 index 0000000..74f794a --- /dev/null +++ b/src/Validation/SchemaRules/OpeningHoursSpecification.php @@ -0,0 +1,12 @@ + 'string', + 'closes' => 'string', + 'dayOfWeek' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'validFrom' => 'iso_date', + 'validThrough' => 'iso_date', +]; diff --git a/src/Validation/SchemaRules/Organization.php b/src/Validation/SchemaRules/Organization.php new file mode 100644 index 0000000..0c4689e --- /dev/null +++ b/src/Validation/SchemaRules/Organization.php @@ -0,0 +1,22 @@ + 'string', + 'url' => 'url', + 'logo' => 'string', + 'address' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'contactPoint' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\ContactPoint|array', + 'item_type' => '\Melbahja\Seo\Schema\Intangible\ContactPoint', + ], + 'sameAs' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'description' => 'string', + 'email' => 'email', + 'telephone' => 'string', + 'foundingDate' => 'iso_date', + 'numberOfEmployees' => '\Melbahja\Seo\Schema\Intangible\QuantitativeValue', +]; diff --git a/src/Validation/SchemaRules/Person.php b/src/Validation/SchemaRules/Person.php new file mode 100644 index 0000000..33a8c7d --- /dev/null +++ b/src/Validation/SchemaRules/Person.php @@ -0,0 +1,16 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'url' => 'url', + 'image' => 'string', + 'sameAs' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'description' => 'string', + 'jobTitle' => 'string', +]; diff --git a/src/Validation/SchemaRules/PlaceRule.php b/src/Validation/SchemaRules/PlaceRule.php new file mode 100644 index 0000000..227d11b --- /dev/null +++ b/src/Validation/SchemaRules/PlaceRule.php @@ -0,0 +1,12 @@ + 'string', + 'address' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'geo' => '\Melbahja\Seo\Schema\Intangible\GeoCoordinates', + 'telephone' => 'string', + 'image' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], +]; diff --git a/src/Validation/SchemaRules/PostalAddress.php b/src/Validation/SchemaRules/PostalAddress.php new file mode 100644 index 0000000..b4817be --- /dev/null +++ b/src/Validation/SchemaRules/PostalAddress.php @@ -0,0 +1,9 @@ + 'string', + 'addressLocality' => 'string', + 'addressRegion' => 'string', + 'postalCode' => 'string', + 'addressCountry' => 'string', +]; diff --git a/src/Validation/SchemaRules/Product.php b/src/Validation/SchemaRules/Product.php new file mode 100644 index 0000000..aaf2106 --- /dev/null +++ b/src/Validation/SchemaRules/Product.php @@ -0,0 +1,27 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'image' => [ + 'type' => 'string|array', + 'item_type' => 'string', + 'required' => true, + ], + 'description' => 'string', + 'brand' => '\Melbahja\Seo\Schema\Intangible\Brand|\Melbahja\Seo\Schema\Organization', + 'offers' => '\Melbahja\Seo\Schema\Intangible\Offer|\Melbahja\Seo\Schema\Intangible\AggregateOffer', + 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', + 'review' => [ + 'type' => '\Melbahja\Seo\Schema\CreativeWork\Review|array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Review', + ], + 'sku' => 'string', + 'mpn' => 'string', + 'gtin' => 'string', +]; diff --git a/src/Validation/SchemaRules/ProfilePage.php b/src/Validation/SchemaRules/ProfilePage.php new file mode 100644 index 0000000..b5e0c43 --- /dev/null +++ b/src/Validation/SchemaRules/ProfilePage.php @@ -0,0 +1,10 @@ + [ + 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'required' => true, + ], + 'dateCreated' => 'iso_date', + 'dateModified' => 'iso_date', +]; diff --git a/src/Validation/SchemaRules/QAPage.php b/src/Validation/SchemaRules/QAPage.php new file mode 100644 index 0000000..5488788 --- /dev/null +++ b/src/Validation/SchemaRules/QAPage.php @@ -0,0 +1,8 @@ + [ + 'type' => '\Melbahja\Seo\Schema\CreativeWork\Question', + 'required' => true, + ], +]; diff --git a/src/Validation/SchemaRules/Question.php b/src/Validation/SchemaRules/Question.php new file mode 100644 index 0000000..f69d07b --- /dev/null +++ b/src/Validation/SchemaRules/Question.php @@ -0,0 +1,23 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'answerCount' => [ + 'type' => 'int', + 'required' => true, + ], + 'acceptedAnswer' => [ + 'type' => '\Melbahja\Seo\Schema\CreativeWork\Answer|array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Answer', + ], + 'suggestedAnswer' => [ + 'type' => '\Melbahja\Seo\Schema\CreativeWork\Answer|array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Answer', + ], + 'text' => 'string', + 'datePublished' => 'iso_date', + 'author' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', +]; diff --git a/src/Validation/SchemaRules/Rating.php b/src/Validation/SchemaRules/Rating.php new file mode 100644 index 0000000..b53120b --- /dev/null +++ b/src/Validation/SchemaRules/Rating.php @@ -0,0 +1,10 @@ + [ + 'type' => 'float', + 'required' => true, + ], + 'bestRating' => 'float', + 'worstRating' => 'float', +]; diff --git a/src/Validation/SchemaRules/Recipe.php b/src/Validation/SchemaRules/Recipe.php new file mode 100644 index 0000000..a8b7db7 --- /dev/null +++ b/src/Validation/SchemaRules/Recipe.php @@ -0,0 +1,32 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'image' => [ + 'type' => 'string|array', + 'item_type' => 'string', + 'required' => true, + ], + 'author' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'datePublished' => 'iso_date', + 'description' => 'string', + 'prepTime' => 'string', + 'cookTime' => 'string', + 'totalTime' => 'string', + 'recipeYield' => 'string', + 'recipeCategory' => 'string', + 'recipeCuisine' => 'string', + 'recipeIngredient' => [ + 'type' => 'array', + 'item_type' => 'string', + ], + 'recipeInstructions' => [ + 'type' => 'array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\HowToStep|\Melbahja\Seo\Schema\CreativeWork\HowToSection', + ], + 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', + 'video' => '\Melbahja\Seo\Schema\CreativeWork\VideoObject', +]; diff --git a/src/Validation/SchemaRules/Restaurant.php b/src/Validation/SchemaRules/Restaurant.php new file mode 100644 index 0000000..b59b5be --- /dev/null +++ b/src/Validation/SchemaRules/Restaurant.php @@ -0,0 +1,18 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'address' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'required' => true, + ], + 'servesCuisine' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'menu' => 'string', + 'acceptsReservations' => 'bool', +]; diff --git a/src/Validation/SchemaRules/Review.php b/src/Validation/SchemaRules/Review.php new file mode 100644 index 0000000..ee17565 --- /dev/null +++ b/src/Validation/SchemaRules/Review.php @@ -0,0 +1,17 @@ + [ + 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'required' => true, + ], + 'reviewRating' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\Rating', + 'required' => true, + ], + 'itemReviewed' => '\Melbahja\Seo\Schema\Thing', + 'datePublished' => 'iso_date', + 'reviewBody' => 'string', +]; diff --git a/src/Validation/SchemaRules/SoftwareApplication.php b/src/Validation/SchemaRules/SoftwareApplication.php new file mode 100644 index 0000000..f8d191e --- /dev/null +++ b/src/Validation/SchemaRules/SoftwareApplication.php @@ -0,0 +1,22 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'offers' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\Offer|\Melbahja\Seo\Schema\Intangible\AggregateOffer', + 'required' => true, + ], + 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', + 'review' => [ + 'type' => '\Melbahja\Seo\Schema\CreativeWork\Review|array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Review', + ], + 'applicationCategory' => 'string', + 'operatingSystem' => 'string', +]; diff --git a/src/Validation/SchemaRules/Store.php b/src/Validation/SchemaRules/Store.php new file mode 100644 index 0000000..bf2ede3 --- /dev/null +++ b/src/Validation/SchemaRules/Store.php @@ -0,0 +1,12 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'address' => [ + 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'required' => true, + ], +]; diff --git a/src/Validation/SchemaRules/VideoObject.php b/src/Validation/SchemaRules/VideoObject.php new file mode 100644 index 0000000..a937074 --- /dev/null +++ b/src/Validation/SchemaRules/VideoObject.php @@ -0,0 +1,28 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'thumbnailUrl' => [ + 'type' => 'string|array', + 'item_type' => 'string', + 'required' => true, + ], + 'uploadDate' => [ + 'type' => 'iso_date', + 'required' => true, + ], + 'contentUrl' => 'url', + 'description' => 'string', + 'duration' => 'string', + 'embedUrl' => 'url', + 'expires' => 'iso_date', + 'hasPart' => [ + 'type' => '\Melbahja\Seo\Schema\CreativeWork\Clip|array', + 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Clip', + ], + 'publication' => '\Melbahja\Seo\Schema\CreativeWork\BroadcastEvent', +]; diff --git a/src/Validation/SchemaRules/VirtualLocation.php b/src/Validation/SchemaRules/VirtualLocation.php new file mode 100644 index 0000000..33345e0 --- /dev/null +++ b/src/Validation/SchemaRules/VirtualLocation.php @@ -0,0 +1,5 @@ + 'url', +]; diff --git a/src/Validation/SchemaRules/WebPage.php b/src/Validation/SchemaRules/WebPage.php new file mode 100644 index 0000000..02efe2e --- /dev/null +++ b/src/Validation/SchemaRules/WebPage.php @@ -0,0 +1,10 @@ + 'string', + 'description' => 'string', + 'datePublished' => 'iso_date', + 'dateModified' => 'iso_date', + 'mainEntity' => '\Melbahja\Seo\Schema\Thing', + 'breadcrumb' => '\Melbahja\Seo\Schema\Intangible\BreadcrumbList', +]; diff --git a/src/Validation/SchemaValidator.php b/src/Validation/SchemaValidator.php new file mode 100644 index 0000000..e3a768f --- /dev/null +++ b/src/Validation/SchemaValidator.php @@ -0,0 +1,235 @@ + $rule) + { + $value = $data[$prop] ?? null; + + if (is_string($rule)) { + $rule = ['type' => $rule]; + } + + // Check required + if (!empty($rule['required']) && self::isEmpty($value)) { + $errors[] = "{$prop} is required"; + continue; + } + + // Skip if not required and empty + if (empty($rule['required']) && self::isEmpty($value)) { + continue; + } + + // Check type + if (isset($rule['type'])) { + $typeErrors = self::validateTypeValue($prop, $value, $rule['type'], $rule); + if ($typeErrors !== null) { + $errors = array_merge($errors, $typeErrors); + } + } + } + + return empty($errors) ? null : $errors; + } + + private static function validateTypeValue(string $prop, $value, string $type, array $rule): ?array + { + $errors = []; + + // Handle union types (string|array) + if (strpos($type, '|') !== false) + { + $typeMatched = false; + foreach (explode('|', $type) as $singleType) + { + $singleType = trim($singleType); + $typeErrors = self::checkType($value, $singleType, $rule); + + if ($typeErrors === null) { + $typeMatched = true; + break; + } + } + + if (!$typeMatched) { + return ["{$prop} must be one of: {$type}"]; + } + + return null; + } + + // Single type check + $typeErrors = self::checkType($value, $type, $rule); + if ($typeErrors !== null) { + + foreach ($typeErrors as $error) + { + // For nested errors, prepend the property name + if (strpos($error, '.') !== false) { + $errors[] = "{$prop}.{$error}"; + } else { + $errors[] = "{$prop}: {$error}"; + } + } + + return $errors; + } + + return null; + } + + private static function checkType($value, string $type, array $rule): ?array + { + $errors = []; + + // Built-in types + switch ($type) + { + case 'string': + if (!is_string($value)) { + $errors[] = "must be a string"; + } + break; + + case 'int': + case 'integer': + if (!is_int($value)) { + $errors[] = "must be an integer"; + } + break; + + case 'float': + + if (!is_float($value)) { + $errors[] = "must be a float"; + } + break; + + case 'bool': + case 'boolean': + + if (!is_bool($value)) { + $errors[] = "must be a boolean"; + } + break; + + case 'array': + + if (!is_array($value)) { + $errors[] = "must be an array"; + } elseif (isset($rule['array_item_type'])) { + // Check array items if specified + foreach ($value as $index => $item) { + $itemErrors = self::checkType($item, $rule['array_item_type'], []); + if ($itemErrors !== null) { + foreach ($itemErrors as $error) { + $errors[] = "[{$index}] {$error}"; + } + } + } + } + break; + + case 'iso_date': + if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}/', $value)) { + $errors[] = "must be a valid ISO date (YYYY-MM-DD)"; + } + break; + + case 'url': + if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_URL)) { + $errors[] = "must be a valid URL"; + } + break; + + case 'email': + if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_EMAIL)) { + $errors[] = "must be a valid email"; + } + break; + + default: + // Class type + if (!class_exists($type)) { + $errors[] = "class {$type} does not exist"; + break; + } + + // If value is instance of class + if ($value instanceof $type) { + // Check if class has a validate method + if (method_exists($value, 'validate')) { + $nestedErrors = $value->validate(); + if ($nestedErrors !== null) { + $errors = array_merge($errors, $nestedErrors); + } + } + break; + } + + // If value is array, load and validate against class rules + if (is_array($value)) { + $className = self::getClassNameFromType($type); + $classRules = self::loadRules($className); + + // If no rules for class, fallback to instance check + if (empty($classRules)) { + $errors[] = "must be an instance of {$type}"; + break; + } + + // Recursively validate array against class rules + $nestedErrors = self::validate($className, $value); + if ($nestedErrors !== null) { + $errors = array_merge($errors, $nestedErrors); + } + break; + } + + $errors[] = "must be an instance of {$type} or array representing {$type}"; + break; + } + + return empty($errors) ? null : $errors; + } + + private static function getClassNameFromType(string $type): string + { + $parts = explode('\\', $type); + return end($parts); + } + + private static function loadRules(string $schemaType): array + { + if (!file_exists($ruleFile = __DIR__ . "/SchemaRules/{$schemaType}.php")) { + return []; + } + + return include $ruleFile; + } + + private static function isEmpty($value): bool + { + if (is_array($value)) { + return empty($value); + } else if (is_string($value)) { + return trim($value) === ''; + } + + return $value === null; + } +} diff --git a/tests/MetaTagsTest.php b/tests/MetaTagsTest.php index 9ea56fd..0937f58 100644 --- a/tests/MetaTagsTest.php +++ b/tests/MetaTagsTest.php @@ -1,6 +1,7 @@ setUrl("https://example.com") - ->setLogo("https://example.com/logo.png") - ->setContactPoint((new ContactPoint()) - ->setTelephone("+1-000-555-1212") - ->setContactType("customer service"));; - $schema=new Schema($organization); - - $this->assertEquals('{"@context":"https:\/\/schema.org","@graph":[{"url":"https:\/\/example.com","logo":"https:\/\/example.com\/logo.png","contactPoint":{"telephone":"+1-000-555-1212","contactType":"customer service","@type":"ContactPoint","@context":"https:\/\/schema.org\/"},"@type":"Organization","@context":"https:\/\/schema.org\/"}]}', json_encode($schema)); - - } -} diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php deleted file mode 100644 index bc96e27..0000000 --- a/tests/SchemaTest.php +++ /dev/null @@ -1,54 +0,0 @@ - 'https://example.com', - 'logo' => 'https://example.com/logo.png', - 'contactPoint' => new Thing('ContactPoint', [ - 'telephone' => '+1-000-555-1212', - 'contactType' => 'customer service' - ]) - ]) - ); - - - $this->assertEquals('{"@context":"https:\/\/schema.org","@graph":[{"url":"https:\/\/example.com","logo":"https:\/\/example.com\/logo.png","contactPoint":{"telephone":"+1-000-555-1212","contactType":"customer service","@type":"ContactPoint","@context":"https:\/\/schema.org\/"},"@type":"Organization","@context":"https:\/\/schema.org\/"}]}', json_encode($schema)); - - $product = new Thing('Product'); - $product->name = "Foo Bar"; - $product->sku = "sk12"; - $product->image = "/image.jpeg"; - $product->description = "testing"; - $product->offers = new Thing('Offer', [ - 'availability' => 'https://schema.org/InStock', - 'priceCurrency' => 'USD', - "price" => "119.99", - 'url' => 'https://gool.com', - ]); - - $webpage = new Thing("WebPage", [ - '@id' => "https://example.com/product/#webpage", - 'url' => "https://example.com/product", - 'name' => 'Foo Bar', - ]); - - - $schema = new Schema( - $product, - $webpage - ); - - - $this->assertEquals('', (string) $schema); - - } -} diff --git a/tests/SchemaValidationTest.php b/tests/SchemaValidationTest.php new file mode 100644 index 0000000..bc71f1a --- /dev/null +++ b/tests/SchemaValidationTest.php @@ -0,0 +1,85 @@ + 'Casablanca Cafe', + 'address' => [ + 'streetAddress' => '123 Avenue Mohammed V', + 'addressLocality' => 'Casablanca', + 'addressRegion' => 'Casablanca-Settat', + 'postalCode' => '20000', + 'addressCountry' => 'MA' + ], + 'url' => 'https://example.com', + 'telephone' => '+212524111111', + 'priceRange' => '$$', + 'servesCuisine' => ['Moroccan', 'Mediterranean'] + ]); + + + + $errors = SchemaValidator::validate('LocalBusiness', $biz->jsonSerialize()); + $this->assertNull($errors, 'LocalBusiness validation should pass with valid data'); + } + + public function testLocalBusinessValidatorWithMissingRequiredFields() + { + // Test with missing required fields + $data = [ + 'url' => 'https://example.com', + 'telephone' => '+212524111111' + // Missing required 'name' and 'address' + ]; + + $errors = SchemaValidator::validate('LocalBusiness', $data); + $this->assertIsArray($errors); + $this->assertContains('name is required', $errors); + $this->assertContains('address is required', $errors); + } + + public function testLocalBusinessValidatorWithInvalidTypes() + { + // Test with invalid data types + $data = [ + 'name' => 123, // Should be string + 'address' => 'not an array', // Should be array or PostalAddress + 'url' => 'invalid-url', // Invalid URL + 'telephone' => 212524111111, // Should be string + 'priceRange' => 100, // Should be string + 'servesCuisine' => 123, // Should be string or array + 'acceptsReservations' => 'yes' // Should be bool + ]; + + $errors = SchemaValidator::validate('LocalBusiness', $data); + $this->assertIsArray($errors); + } + + public function testLocalBusinessValidatorWithValidNestedAddress() + { + // Test with valid nested PostalAddress as array + $data = [ + 'name' => 'Marrakech Restaurant', + 'address' => [ + 'streetAddress' => '456 Rue de la Koutoubia', + 'addressLocality' => 'Marrakech', + 'addressRegion' => 'Marrakech-Safi', + 'postalCode' => '40000', + 'addressCountry' => 'MA' + ], + 'url' => 'https://example.com', + 'telephone' => '+212524111111' + ]; + + $errors = SchemaValidator::validate('LocalBusiness', $data); + $this->assertNull($errors, 'Should pass with valid nested address array'); + } +} diff --git a/tests/SitemapsTest.php b/tests/SitemapsTest.php index c93bb01..34abb26 100644 --- a/tests/SitemapsTest.php +++ b/tests/SitemapsTest.php @@ -1,6 +1,7 @@ Date: Wed, 7 Jan 2026 18:11:58 +0100 Subject: [PATCH 02/45] [Improv]: robots.txt generator. --- src/Robots.php | 135 +++++++++++++++++++++++++++---------------- tests/RobotsTest.php | 96 +++++++++++++++++++++++++----- 2 files changed, 167 insertions(+), 64 deletions(-) diff --git a/src/Robots.php b/src/Robots.php index 7fd46ab..715bbc8 100644 --- a/src/Robots.php +++ b/src/Robots.php @@ -1,101 +1,134 @@ rules[] = ['type' => 'comment', 'text' => $text]; + return $this; + } /** - * Add rules for bot by user agent name. + * Add a sitemap URL. * - * @param string $userAgent bot user agent name - * @param array $rules + * @param string $url Sitemap URL * @return Robots */ - public function bot(string $userAgent, array $rules): Robots + public function addSitemap(string $url): Robots { - $this->rules[$userAgent] = $rules; + $this->rules[] = ['type' => 'sitemap', 'url' => $url]; return $this; } /** - * Set sitemap url. + * Add rules for a bot by user agent name. * - * @param string $url + * @param string $userAgent Bot user agent name + * @param array $disallow Array of paths to disallow + * @param array $allow Array of paths to allow + * @param int|null $crawlDelay Crawl delay in seconds * @return Robots */ - public function sitemap(string $url): Robots + public function addRule( + string $userAgent = '*', + array $disallow = [], + array $allow = [], + ?int $crawlDelay = null + ): Robots { - $this->sitemaps[] = $url; + $this->rules[] = [ + 'type' => 'rule', + 'userAgent' => $userAgent, + 'disallow' => $disallow, + 'allow' => $allow, + 'crawlDelay' => $crawlDelay + ]; + return $this; } + /** + * Save robots txt to a file. + * + * @param string $path + * @return bool + */ + public function saveTo(string $path): bool + { + return file_put_contents($path, (string) $this) !== false; + } /** - * Build robots rules. + * Build robots txt content. * * @return string */ public function __toString(): string { - $out = "# Autogenerated by melbahja/seo\r\n"; + $out = ""; - foreach ($this->sitemaps as $url) + foreach ($this->rules as $rule) { - $out .= "Sitemap: {$url}\r\n"; - } - - if ($out !== "") { - $out .= "\r\n"; - } - - foreach($this->rules as $agent => $rules) - { - $out .= "User-agent: {$agent}\r\n"; - - if (isset($rules['allow'])) { - foreach ($rules['allow'] as $v) - { - $out .= "Allow: {$v}\r\n"; - } + switch ($rule['type']) + { + case 'comment': + $lines = explode("\n", $rule['text']); + foreach ($lines as $line) + { + $out .= "# {$line}\r\n"; + } + break; + + case 'sitemap': + $out .= "Sitemap: {$rule['url']}\r\n"; + break; + + case 'rule': + + $out .= "User-agent: {$rule['userAgent']}\r\n"; + foreach ($rule['disallow'] as $path) + { + $out .= "Disallow: {$path}\r\n"; + } + + foreach ($rule['allow'] as $path) + { + $out .= "Allow: {$path}\r\n"; + } + + if ($rule['crawlDelay'] !== null) { + $out .= "Crawl-delay: {$rule['crawlDelay']}\r\n"; + } + + $out .= "\r\n"; + break; } - - if (isset($rules['disallow'])) { - foreach($rules['disallow'] as $v) - { - $out .= "Disallow: {$v}\r\n"; - } - } - - if (isset($rules['delay'])) { - $out .= "Crawl-delay: {$rules['delay']}\r\n"; - } - - $out .= "\r\n"; } return $out; } - } diff --git a/tests/RobotsTest.php b/tests/RobotsTest.php index 0327116..a34c914 100644 --- a/tests/RobotsTest.php +++ b/tests/RobotsTest.php @@ -6,25 +6,95 @@ class RobotsTest extends TestCase { + public function testBasicRule() + { + $robots = new Robots(); + $robots->addRule('*', ['/admin'], ['/public']); + + $expected = "User-agent: *\r\nDisallow: /admin\r\nAllow: /public\r\n\r\n"; + $this->assertEquals($expected, (string) $robots); + } + + public function testMultipleRules() + { + $robots = new Robots(); + $robots->addRule('*', ['/private', '/admin'], ['/', '/public'], 5); + $robots->addRule('googlebot', [], [], 1); - public function testRobotsResults() + $expected = "User-agent: *\r\nDisallow: /private\r\nDisallow: /admin\r\nAllow: /\r\nAllow: /public\r\nCrawl-delay: 5\r\n\r\nUser-agent: googlebot\r\nCrawl-delay: 1\r\n\r\n"; + $this->assertEquals($expected, (string) $robots); + } + + public function testSitemap() { $robots = new Robots(); - $robots->bot("*", [ - 'allow' => ['/', '/dashboard/login'], - 'disallow' => ['/private', '/dashboard/'], - 'delay' => 5 - ]); + $robots->addSitemap('https://example.com/sitemap.xml'); - $robots->bot("googlebot", [ - 'delay' => 1 - ]); + $expected = "Sitemap: https://example.com/sitemap.xml\r\n"; + $this->assertEquals($expected, (string) $robots); + } - $robots->sitemap('https://example.com/sitemaps.xml'); + public function testComment() + { + $robots = new Robots(); + $robots->addComment('Custom robots.txt'); + + $expected = "# Custom robots.txt\r\n"; + $this->assertEquals($expected, (string) $robots); + } + + public function testMixedOrder() + { + $robots = new Robots(); + $robots->addComment('Website robots.txt'); + $robots->addSitemap('https://example.com/sitemap.xml'); + $robots->addRule('*', ['/admin']); + $robots->addComment('Block bad bots'); + $robots->addRule('BadBot', ['/']); + + $expected = "# Website robots.txt\r\nSitemap: https://example.com/sitemap.xml\r\nUser-agent: *\r\nDisallow: /admin\r\n\r\n# Block bad bots\r\nUser-agent: BadBot\r\nDisallow: /\r\n\r\n"; + $this->assertEquals($expected, (string) $robots); + } + + public function testMultipleSitemaps() + { + $robots = new Robots(); + $robots->addSitemap('https://example.com/sitemap.xml'); + $robots->addSitemap('https://example.com/sitemap-news.xml'); + + $expected = "Sitemap: https://example.com/sitemap.xml\r\nSitemap: https://example.com/sitemap-news.xml\r\n"; + $this->assertEquals($expected, (string) $robots); + } + + public function testMultilineComment() + { + $robots = new Robots(); + $robots->addComment("Line 1\nLine 2"); + + $expected = "# Line 1\r\n# Line 2\r\n"; + $this->assertEquals($expected, (string) $robots); + } + + public function testStringable() + { + $robots = new Robots(); + $robots->addRule('*', ['/admin']); + + $this->assertIsString((string) $robots); + } + + public function testSaveTo() + { + $robots = new Robots(); + $robots->addRule('*', ['/admin']); + $path = sys_get_temp_dir() . '/test_robots.txt'; + $result = $robots->saveTo($path); - $expecting = "# Autogenerated by melbahja/seo\r\nSitemap: https://example.com/sitemaps.xml\r\n\r\nUser-agent: *\r\nAllow: /\r\nAllow: /dashboard/login\r\nDisallow: /private\r\nDisallow: /dashboard/\r\nCrawl-delay: 5\r\n\r\nUser-agent: googlebot\r\nCrawl-delay: 1\r\n\r\n"; + $this->assertTrue($result); + $this->assertFileExists($path); + $this->assertEquals((string) $robots, file_get_contents($path)); - $this->assertEquals($expecting, (string) $robots); + unlink($path); } -} +} \ No newline at end of file From 12863d70f7c111561610b800708727497baf1f47 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Wed, 7 Jan 2026 18:12:11 +0100 Subject: [PATCH 03/45] [added]: change log --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b898ab3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# CHANGELOG + +## v3 - Jan/2026 + +### Breaking Changes: +* [Robots] [CHANGED] `bot()` method changed to `addRule()` - params structure is different +* [Robots] [CHANGED] `sitemap()` method renamed to `addSitemap()` +* [Robots] [CHANGED] `delay` array key renamed to `crawlDelay` +* [Robots] [ NEW ] `addComment()` - adds comments to robots.txt +* [Robots] [ NEW ] `saveTo()` - saves to file +* [Robots] [ NEW ] Implements `Stringable` interface +* [Robots] [REMOVED] Robots copyrights header removed + From d3c529a638a34cd11a3135e5b399ea5e2ee6c4b1 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Wed, 7 Jan 2026 18:59:46 +0100 Subject: [PATCH 04/45] [added]: v3 robots text validator and test. --- src/Validation/RobotsValidator.php | 108 +++++++++++++++++++++++++++++ tests/RobotsValidationTest.php | 66 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/Validation/RobotsValidator.php create mode 100644 tests/RobotsValidationTest.php diff --git a/src/Validation/RobotsValidator.php b/src/Validation/RobotsValidator.php new file mode 100644 index 0000000..b0aacda --- /dev/null +++ b/src/Validation/RobotsValidator.php @@ -0,0 +1,108 @@ + $line) + { + $line = trim($line); + $realLine = $lineNum + 1; + + // Skip empty lines and comments + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + // Split by first colon + $parts = explode(':', $line, 2); + + if (count($parts) !== 2) { + $errors[] = "Line $realLine: Invalid format, missing colon"; + continue; + } + + $direc = trim(strtolower($parts[0])); + $value = trim($parts[1]); + + switch ($direc) + { + case 'user-agent': + if (empty($value)) { + $errors[] = "Line $realLine: User-agent cannot be empty"; + } + + $currentAgent = $value; + $hasUserAgent = true; + break; + + case 'disallow': + case 'allow': + if ($currentAgent === null) { + $errors[] = "Line $realLine: $direc must come after User-agent"; + } + if ($value !== '' && !str_starts_with($value, '/')) { + $errors[] = "Line $realLine: Path must start with / or be empty"; + } + break; + + case 'crawl-delay': + if ($currentAgent === null) { + $errors[] = "Line $realLine: Crawl-delay must come after User-agent"; + } + if (!is_numeric($value) || $value < 0) { + $errors[] = "Line $realLine: Crawl-delay must be non-negative number"; + } + break; + + case 'sitemap': + if (!filter_var($value, FILTER_VALIDATE_URL)) { + $errors[] = "Line $realLine: Invalid sitemap URL"; + } + break; + + default: + $errors[] = "Line $realLine: Unknown directive '$direc'"; + break; + } + } + + if (!$hasUserAgent && !empty($errors)) { + $errors[] = "No User-agent directive found"; + } + + return empty($errors) ? null : $errors; + } +} diff --git a/tests/RobotsValidationTest.php b/tests/RobotsValidationTest.php new file mode 100644 index 0000000..8adba12 --- /dev/null +++ b/tests/RobotsValidationTest.php @@ -0,0 +1,66 @@ +assertNull($errors); + + // Empty file/rules + $this->assertNull(RobotsValidator::validate('')); + } + + public function testValidWithRobotsObject() + { + $robots = new Robots(); + $robots->addRule('*', ['/admin'], ['/public'], 5); + $robots->addSitemap('https://example.com/sitemap.xml'); + + $errors = RobotsValidator::validate($robots); + + $this->assertNull($errors); + } + + + public function testInvalidRobots() + { + // Missing User-agent before Disallow + $invalid = "Disallow: /admin\r\n"; + + $errors = RobotsValidator::validate($invalid); + + $this->assertIsArray($errors); + $this->assertNotEmpty($errors); + } + + public function testMultipleErrors() + { + $invalid = "User-agent: \r\nDisallow: admin\r\nCrawl-delay: -5\r\nSitemap: not-a-url"; + + $errors = RobotsValidator::validate($invalid); + + $this->assertIsArray($errors); + $this->assertCount(4, $errors); + } + + public function testInvalidFormat() + { + $invalid = "User-agent *\r\nSomething without colon"; + + $errors = RobotsValidator::validate($invalid); + + $this->assertIsArray($errors); + $this->assertStringContainsString('missing colon', $errors[0]); + } +} From ab323123f56f9d3b6871fcb169af584bbc4694ad Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Thu, 8 Jan 2026 00:19:11 +0100 Subject: [PATCH 05/45] [add] new php versions in wf tests --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eaed91a..4f90723 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ jobs: max-parallel: 3 matrix: os: [ubuntu-latest] - php: [8.1, 8.2, 8.3] + php: [8.1, 8.2, 8.3, 8.4, 8.5] experimental: [false] name: PHP ${{ matrix.php }} test on ${{ matrix.os }} From 290a1d4df7696125f887b584841c2012edcd904f Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Thu, 8 Jan 2026 00:20:06 +0100 Subject: [PATCH 06/45] [removed] bing indexer class not needed since we use indexnowindexer --- src/Indexing/BingIndexer.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/Indexing/BingIndexer.php diff --git a/src/Indexing/BingIndexer.php b/src/Indexing/BingIndexer.php deleted file mode 100644 index e69de29..0000000 From b5344b802ad1cfd5def1286d9e21f9d4b4dbafe8 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Thu, 8 Jan 2026 00:20:53 +0100 Subject: [PATCH 07/45] [removed] sitemap ping not supported and deprecated by almost all engines --- src/Indexing/SitemapPinger.php | 69 ---------------------------------- 1 file changed, 69 deletions(-) delete mode 100644 src/Indexing/SitemapPinger.php diff --git a/src/Indexing/SitemapPinger.php b/src/Indexing/SitemapPinger.php deleted file mode 100644 index fc92122..0000000 --- a/src/Indexing/SitemapPinger.php +++ /dev/null @@ -1,69 +0,0 @@ -engines = array_unique(array_merge($this->engines, $append)); - } - } - - /** - * Send sitemap url to registred engines - * - * @param string $sitemapUrl - * @return void - */ - public function send(string $sitemapUrl): void - { - foreach ($this->engines as $engine) - { - $this->inform($engine, $sitemapUrl); - } - } - - /** - * Inform search engine - * - * @param string $engine - * @param string $url - * @return void - */ - public function inform(string $engine, string $url): void - { - $req = curl_init("{$engine}/ping?sitemap={$url}"); - curl_setopt($req, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($req, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($req, CURLOPT_RETURNTRANSFER, 1); - curl_exec($req); - curl_close($req); - } -} From 2b269d23aeeaf496833aa067e2e98291d75b1165 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Thu, 8 Jan 2026 00:21:37 +0100 Subject: [PATCH 08/45] [add/refactor] search engine indexer clients. --- src/Indexing/GoogleIndexer.php | 98 ++++++++++++++++++++++++ src/Indexing/IndexNowEngine.php | 30 ++++++++ src/Indexing/IndexNowIndexer.php | 123 +++++++++++++++++++------------ src/Indexing/URLIndexingType.php | 15 ++++ 4 files changed, 220 insertions(+), 46 deletions(-) create mode 100644 src/Indexing/IndexNowEngine.php create mode 100644 src/Indexing/URLIndexingType.php diff --git a/src/Indexing/GoogleIndexer.php b/src/Indexing/GoogleIndexer.php index e69de29..07554a4 100644 --- a/src/Indexing/GoogleIndexer.php +++ b/src/Indexing/GoogleIndexer.php @@ -0,0 +1,98 @@ +accessToken = $accessToken; + $this->httpClient = $httpClient ?? new HttpClient(null, + [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json' + ]); + } + + /** + * Submit multiple URLs to google indexing API + * + * @todo Nice to support batch operation later. + * @param array $urls list of URLs to submit for indexing + * @param URLIndexingType $type The type of indexing operation UPDATE or DELETE + * @return array associative, URLs as keys and values as bool success state + */ + public function submitUrls(array $urls, URLIndexingType $type = URLIndexingType::UPDATE): array + { + $results = []; + foreach ($urls as $url) + { + $results[$url] = $this->submitUrl($url, $type); + } + + return $results; + } + + /** + * Submit a single URL to google indexing API + * + * @param string $url The URL to submit + * @param URLIndexingType $type The type of indexing operation UPDATE or DELETE + * @return bool true if HTTP status is 200, false otherwise + */ + public function submitUrl(string $url, URLIndexingType $type = URLIndexingType::UPDATE): bool + { + $payload = [ + 'url' => $url, + 'type' => $type === URLIndexingType::UPDATE ? 'URL_UPDATED' : 'URL_DELETED' + ]; + + $this->httpClient->request('POST', self::API_URL, $payload); + return $this->httpClient->getStatusCode() === 200; + } + + /** + * Not supported - this is just for IndexNow compatibility! + * + * @return never This method always throws an exception + * @throws RuntimeException Always throws as Google doesn't use this verification method + */ + public function serveKeyFile(): never + { + throw new RuntimeException('Google Indexing API does not use key.txt verification'); + } + + /** + * Create an GoogleIndexer instance from environment variable + * + * @param string $envVar The name of the env var of the API key, INDEXNOW_API_KEY by default. + * @return self New GoogleIndexer instance + * @throws RuntimeException If the environment variable is not set or empty + */ + public static function fromEnvironment(string $envVar = 'GOOGLE_INDEXING_ACCESS_TOKEN'): self + { + if (!($token = $_ENV[$envVar] ?? getenv($envVar))) { + throw new RuntimeException("Google Indexing API access token not found in env var: {$envVar}"); + } + + return new self($token); + } +} diff --git a/src/Indexing/IndexNowEngine.php b/src/Indexing/IndexNowEngine.php new file mode 100644 index 0000000..8cab616 --- /dev/null +++ b/src/Indexing/IndexNowEngine.php @@ -0,0 +1,30 @@ +value, urlencode($url), urlencode($key)); + } +} diff --git a/src/Indexing/IndexNowIndexer.php b/src/Indexing/IndexNowIndexer.php index 96da0a3..bbe90d9 100644 --- a/src/Indexing/IndexNowIndexer.php +++ b/src/Indexing/IndexNowIndexer.php @@ -1,88 +1,119 @@ apiKey = $apiKey; + $this->httpClient = $httpClient ?? new HttpClient(); + } /** - * Initialize indexer. - * @param string $host - * @param array $keys + * Submit a single URL to $engine for indexing + * + * @param string $url The URL to submit for indexing + * @param IndexNowEngine|null $engine The search engine to notify defaults to indexnow all supported engines + * @param URLIndexingType $type The type of indexing operation not needed now y can just send new or 404 urls + * @return bool true on successful, false on failure. */ - public function __construct(string $host, array $keys) + public function submitUrl(string $url, ?IndexNowEngine $engine = null, URLIndexingType $type = URLIndexingType::UPDATE): bool { - $this->host = $host; - $this->keys = $keys; + $engine = $engine ?? IndexNowEngine::INDEXNOW; + + return $this->sendRequest($engine->toUrl($url, $this->apiKey)); } /** - * Instant index single url. + * Submit multiple URLs to $engine for indexing * - * @param string $url - * @return array + * @param array $urls Array of URLs to submit for indexing + * @param IndexNowEngine|null $engine The search engine to notify defaults to indexnow all supported engines + * @param URLIndexingType $type The type of indexing operation not needed now y can just send new or 404 urls + * @return array associative, URLs as keys and values as bool success state */ - public function indexUrl(string $url): array + public function submitUrls(array $urls, ?IndexNowEngine $engine = null, URLIndexingType $type = URLIndexingType::UPDATE): array { - return $this->indexUrls([$url]); + $results = []; + foreach ($urls as $url) + { + $results[$url] = $this->submitUrl($url, $engine, $type); + } + + return $results; } /** - * Instant index multiple urls. + * Serve the IndexNow verification key file + * + * This method handles requests to /{api-key}.txt and returns the key for domain verification. + * Returns 404 if the requested path doesn't match the expected key file path. * - * @param array $urls - * @return array + * @return never This method always exits and terminates runtime. */ - public function indexUrls(array $urls): array + public function serveKeyFile(): never { - $accepted = []; + $uri = strtok($_SERVER['REQUEST_URI'] ?? '', '?'); + $exp = '/' . $this->apiKey . '.txt'; - foreach($this->keys as $engine => $key) - { - $accepted[$engine] = $this->index($engine, $key, $urls); + // Ignore/404 invalid attempts or revoked keys. + if ($uri !== $exp) { + + http_response_code(404); + header('Content-Type: text/plain'); + echo 'Key file not found'; + exit; } - return $accepted; + header('Content-Type: text/plain'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + echo $this->apiKey; + exit; } /** - * Send index request to search engine. + * Send the request to indexnow API * - * @param string $engine - * @param string $apiKey - * @param array $urls - * @return bool + * @param string $url The API URL to send the request + * @return bool assumes sucess if status is less than 400, false otherwise */ - protected function index(string $engine, string $apiKey, array $urls): bool + private function sendRequest(string $url): bool { - $ch = curl_init("https://{$engine}/indexnow"); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['host' => $this->host, 'key' => $apiKey, 'urlList' => $urls])); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['content-type: application/json']); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + $this->httpClient->request('GET', $url); + return $this->httpClient->getStatusCode() < 400; + } + + /** + * Create an IndexNowIndexer instance from environment variable + * + * @param string $envVar The name of the env var of the API key, INDEXNOW_API_KEY by default. + * @return self New IndexNowIndexer instance + * @throws RuntimeException If the environment variable is not set or empty + */ + public static function fromEnvironment(string $envVar = 'INDEXNOW_API_KEY'): self + { + if (!($key = $_ENV[$envVar] ?? getenv($envVar))) { + throw new RuntimeException("IndexNow API key not found in env var: {$envVar}"); + } - return $code >= 200 && $code < 300; + return new self($key); } } diff --git a/src/Indexing/URLIndexingType.php b/src/Indexing/URLIndexingType.php new file mode 100644 index 0000000..dca98ba --- /dev/null +++ b/src/Indexing/URLIndexingType.php @@ -0,0 +1,15 @@ + Date: Thu, 8 Jan 2026 00:22:00 +0100 Subject: [PATCH 09/45] [added] new simple http client --- src/Utils/HttpClient.php | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/Utils/HttpClient.php diff --git a/src/Utils/HttpClient.php b/src/Utils/HttpClient.php new file mode 100644 index 0000000..f0c29f6 --- /dev/null +++ b/src/Utils/HttpClient.php @@ -0,0 +1,98 @@ +baseUrl = $baseUrl ? rtrim($baseUrl, '/') : null; + $this->headers = $headers ?? []; + } + + /** + * Execute HTTP request + * + * @param string $method HTTP method GET, POST... + * @param string $url Full URL or path to append to base URL + * @param mixed $body Request body if array will auto JSON encoded + * @param array $headers Additional headers + * @return string|null Response body or null on failure + */ + public function request(string $method, string $url, $body = null, array $headers = []): ?string + { + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, $this->buildUrl($url)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + + $isJson = false; + if ($body !== null) { + if (is_array($body)) { + $body = json_encode($body); + $isJson = true; + } + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $headers = array_merge($this->headers, $headers); + $headersList = []; + foreach ($headers as $key => $value) + { + // Just skip it! + if ($isJson && strtolower($key) === 'content-type') { + continue; + } + $headersList[] = "$key: $value"; + } + + if ($isJson) { + $headersList[] = "content-type: application/json"; + } + curl_setopt($ch, CURLOPT_HTTPHEADER, $headersList); + + $response = curl_exec($ch); + $this->lastStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + curl_close($ch); + + return $response !== false ? $response : null; + } + + /** + * Get last response status code + * + * @return int HTTP status code + */ + public function getStatusCode(): int + { + return $this->lastStatus; + } + + /** + * Build full URL + */ + private function buildUrl(string $url): string + { + if (filter_var($url, FILTER_VALIDATE_URL)) { + return $url; + } + + return $this->baseUrl . '/' . ltrim($url, '/'); + } +} \ No newline at end of file From 5783e61d1ddaa9d90de16b6df18c03e1831f3c67 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Thu, 8 Jan 2026 00:23:25 +0100 Subject: [PATCH 10/45] [change] add changelog for recent changes --- CHANGELOG.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b898ab3..d7ca710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,13 @@ ## v3 - Jan/2026 ### Breaking Changes: -* [Robots] [CHANGED] `bot()` method changed to `addRule()` - params structure is different -* [Robots] [CHANGED] `sitemap()` method renamed to `addSitemap()` -* [Robots] [CHANGED] `delay` array key renamed to `crawlDelay` -* [Robots] [ NEW ] `addComment()` - adds comments to robots.txt -* [Robots] [ NEW ] `saveTo()` - saves to file -* [Robots] [ NEW ] Implements `Stringable` interface -* [Robots] [REMOVED] Robots copyrights header removed +* [ Robots ] [CHANGED] `bot()` method changed to `addRule()` - params structure is different +* [ Robots ] [CHANGED] `sitemap()` method renamed to `addSitemap()` +* [ Robots ] [CHANGED] `delay` array key renamed to `crawlDelay` +* [ Robots ] [ NEW ] `addComment()` - adds comments to robots.txt +* [ Robots ] [ NEW ] `saveTo()` - saves to file +* [ Robots ] [ NEW ] Implements `Stringable` interface +* [ Robots ] [REMOVED] Robots copyrights header removed +* [ Indexing ] [REMOVED] Sitemap Ping class, /ping?sitemap deprecated/removed by major search engines. +* [ Indexing ] [CHANGED] Refactored indexer classes! From ee78b828b01f4fd947131e866ce0c20917feb31b Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:25:39 +0100 Subject: [PATCH 11/45] formatting --- phpunit.xml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index a69b6a4..49792e4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,14 +7,14 @@ cacheResult="false" cacheDirectory=".phpunit.cache" > - - - tests - - - - - src - - + + + tests + + + + + src + + From a00279aba0240699a7dd57030fae1721b78139ca Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:26:18 +0100 Subject: [PATCH 12/45] add Stringable to Schema interface --- src/Interfaces/SchemaInterface.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Interfaces/SchemaInterface.php b/src/Interfaces/SchemaInterface.php index a70e9e3..19f7f1b 100644 --- a/src/Interfaces/SchemaInterface.php +++ b/src/Interfaces/SchemaInterface.php @@ -8,7 +8,4 @@ * @license MIT * @copyright Mohamed Elabhja */ -interface SchemaInterface extends SeoInterface, \JsonSerializable -{ - public function __toString(): string; -} +interface SchemaInterface extends SeoInterface, \JsonSerializable, \Stringable { } From 61424ffe4d0a240af132874eb75608fa51484a2b Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:28:34 +0100 Subject: [PATCH 13/45] changed Schema types and JSON output --- src/Schema.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Schema.php b/src/Schema.php index 9113afd..821e5cc 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -5,7 +5,6 @@ /** * @package Melbahja\Seo - * @since v2.0 * @see https://git.io/phpseo * @license MIT * @copyright Mohamed Elabhja @@ -30,14 +29,24 @@ public function __construct(SchemaInterface ...$things) /** * Add schema item to the graph. * - * @param SchemaInterface $thing + * @param Thing $thing */ - public function add(SchemaInterface $thing): SchemaInterface + public function add(Thing $thing): SchemaInterface { $this->things[] = $thing; return $this; } + /** + * Get schema items + * + * @return Thing[] + */ + public function all(): array + { + return $this->things; + } + /** * Get data as array * @@ -45,9 +54,13 @@ public function add(SchemaInterface $thing): SchemaInterface */ public function jsonSerialize(): array { + if (count($this->things) === 1) { + return $this->things[0]->jsonSerialize(); + } + return [ '@context' => 'https://schema.org', - '@graph' => $this->things + '@graph' => $this->things, ]; } From 0bfe4d44a62f65d33294d4c9e758e1426c8ae66d Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:29:38 +0100 Subject: [PATCH 14/45] schema rules --- src/Validation/SchemaRules/AggregateOffer.php | 14 ------ .../SchemaRules/AggregateRating.php | 19 ++++---- src/Validation/SchemaRules/Answer.php | 11 ----- src/Validation/SchemaRules/Article.php | 9 ++-- src/Validation/SchemaRules/Book.php | 27 +++++++++++ src/Validation/SchemaRules/Brand.php | 7 --- src/Validation/SchemaRules/BreadcrumbList.php | 4 +- src/Validation/SchemaRules/ClaimReview.php | 18 ++++++++ src/Validation/SchemaRules/ContactPoint.php | 7 --- src/Validation/SchemaRules/Course.php | 25 ++++++++++- src/Validation/SchemaRules/DataFeed.php | 14 ++++++ src/Validation/SchemaRules/Dataset.php | 45 ++++++++++++------- .../SchemaRules/DiscussionForumPosting.php | 26 +++++++++++ .../SchemaRules/EmployerAggregateRating.php | 13 +++--- src/Validation/SchemaRules/Event.php | 37 ++++++--------- src/Validation/SchemaRules/FAQPage.php | 4 +- src/Validation/SchemaRules/GeoCoordinates.php | 7 --- src/Validation/SchemaRules/Hotel.php | 19 -------- src/Validation/SchemaRules/HowTo.php | 19 -------- src/Validation/SchemaRules/HowToSection.php | 13 ------ src/Validation/SchemaRules/HowToStep.php | 14 ------ src/Validation/SchemaRules/ImageObject.php | 18 +++----- src/Validation/SchemaRules/ItemList.php | 4 +- src/Validation/SchemaRules/JobPosting.php | 42 ++++------------- src/Validation/SchemaRules/ListItem.php | 15 ++----- src/Validation/SchemaRules/LocalBusiness.php | 38 +++++++++------- src/Validation/SchemaRules/MathSolver.php | 13 ++++++ src/Validation/SchemaRules/MediaObject.php | 9 ---- src/Validation/SchemaRules/MonetaryAmount.php | 8 ---- src/Validation/SchemaRules/Movie.php | 23 ++++++++++ src/Validation/SchemaRules/Offer.php | 10 ----- src/Validation/SchemaRules/OnlineStore.php | 3 ++ .../SchemaRules/OpeningHoursSpecification.php | 12 ----- src/Validation/SchemaRules/Organization.php | 9 ++-- src/Validation/SchemaRules/Person.php | 3 +- src/Validation/SchemaRules/Place.php | 22 +++++++++ src/Validation/SchemaRules/PlaceRule.php | 12 ----- src/Validation/SchemaRules/Product.php | 33 ++++++++------ src/Validation/SchemaRules/ProductGroup.php | 22 +++++++++ src/Validation/SchemaRules/ProfilePage.php | 2 +- src/Validation/SchemaRules/QAPage.php | 2 +- src/Validation/SchemaRules/Question.php | 23 ---------- src/Validation/SchemaRules/Quiz.php | 14 ++++++ src/Validation/SchemaRules/Rating.php | 11 +++-- src/Validation/SchemaRules/Recipe.php | 31 ++++++------- src/Validation/SchemaRules/Restaurant.php | 16 ++++--- src/Validation/SchemaRules/Review.php | 5 +-- .../SchemaRules/SoftwareApplication.php | 12 +++-- src/Validation/SchemaRules/Store.php | 12 ----- src/Validation/SchemaRules/VacationRental.php | 43 ++++++++++++++++++ src/Validation/SchemaRules/VideoObject.php | 25 +++-------- .../SchemaRules/VirtualLocation.php | 5 --- src/Validation/SchemaRules/WebPage.php | 10 ----- 53 files changed, 423 insertions(+), 436 deletions(-) delete mode 100644 src/Validation/SchemaRules/AggregateOffer.php delete mode 100644 src/Validation/SchemaRules/Answer.php create mode 100644 src/Validation/SchemaRules/Book.php delete mode 100644 src/Validation/SchemaRules/Brand.php create mode 100644 src/Validation/SchemaRules/ClaimReview.php delete mode 100644 src/Validation/SchemaRules/ContactPoint.php create mode 100644 src/Validation/SchemaRules/DataFeed.php create mode 100644 src/Validation/SchemaRules/DiscussionForumPosting.php delete mode 100644 src/Validation/SchemaRules/GeoCoordinates.php delete mode 100644 src/Validation/SchemaRules/Hotel.php delete mode 100644 src/Validation/SchemaRules/HowTo.php delete mode 100644 src/Validation/SchemaRules/HowToSection.php delete mode 100644 src/Validation/SchemaRules/HowToStep.php create mode 100644 src/Validation/SchemaRules/MathSolver.php delete mode 100644 src/Validation/SchemaRules/MediaObject.php delete mode 100644 src/Validation/SchemaRules/MonetaryAmount.php create mode 100644 src/Validation/SchemaRules/Movie.php delete mode 100644 src/Validation/SchemaRules/Offer.php create mode 100644 src/Validation/SchemaRules/OnlineStore.php delete mode 100644 src/Validation/SchemaRules/OpeningHoursSpecification.php create mode 100644 src/Validation/SchemaRules/Place.php delete mode 100644 src/Validation/SchemaRules/PlaceRule.php create mode 100644 src/Validation/SchemaRules/ProductGroup.php delete mode 100644 src/Validation/SchemaRules/Question.php create mode 100644 src/Validation/SchemaRules/Quiz.php delete mode 100644 src/Validation/SchemaRules/Store.php create mode 100644 src/Validation/SchemaRules/VacationRental.php delete mode 100644 src/Validation/SchemaRules/VirtualLocation.php delete mode 100644 src/Validation/SchemaRules/WebPage.php diff --git a/src/Validation/SchemaRules/AggregateOffer.php b/src/Validation/SchemaRules/AggregateOffer.php deleted file mode 100644 index b3f95ec..0000000 --- a/src/Validation/SchemaRules/AggregateOffer.php +++ /dev/null @@ -1,14 +0,0 @@ - [ - 'type' => 'float', - 'required' => true, - ], - 'priceCurrency' => [ - 'type' => 'string', - 'required' => true, - ], - 'highPrice' => 'float', - 'offerCount' => 'int', -]; diff --git a/src/Validation/SchemaRules/AggregateRating.php b/src/Validation/SchemaRules/AggregateRating.php index 8094265..4ee6527 100644 --- a/src/Validation/SchemaRules/AggregateRating.php +++ b/src/Validation/SchemaRules/AggregateRating.php @@ -1,19 +1,16 @@ [ - 'type' => 'float', - 'required' => true, - ], - 'ratingCount' => [ - 'type' => 'int', + 'itemReviewed' => [ + 'type' => '@Thing', 'required' => true, ], - 'reviewCount' => [ - 'type' => 'int', + 'ratingValue' => [ + 'type' => 'string|int|float', 'required' => true, ], - 'itemReviewed' => '\Melbahja\Seo\Schema\Thing', - 'bestRating' => 'float', - 'worstRating' => 'float', + 'ratingCount' => 'int', + 'reviewCount' => 'int', + 'bestRating' => 'string|int|float', + 'worstRating' => 'string|int|float', ]; diff --git a/src/Validation/SchemaRules/Answer.php b/src/Validation/SchemaRules/Answer.php deleted file mode 100644 index 51a1648..0000000 --- a/src/Validation/SchemaRules/Answer.php +++ /dev/null @@ -1,11 +0,0 @@ - [ - 'type' => 'string', - 'required' => true, - ], - 'datePublished' => 'iso_date', - 'author' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', - 'upvoteCount' => 'int', -]; diff --git a/src/Validation/SchemaRules/Article.php b/src/Validation/SchemaRules/Article.php index 08562c2..5960bda 100644 --- a/src/Validation/SchemaRules/Article.php +++ b/src/Validation/SchemaRules/Article.php @@ -3,14 +3,13 @@ return [ 'headline' => 'string', 'image' => [ - 'type' => 'string|array', - 'item_type' => 'string', + 'type' => 'array|url|@ImageObject', + 'item_type' => 'url|@ImageObject', ], 'datePublished' => 'iso_date', 'dateModified' => 'iso_date', 'author' => [ - 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization|array', - 'item_type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'type' => 'array|@Person|@Organization', + 'item_type' => '@Person|@Organization', ], - 'publisher' => '\Melbahja\Seo\Schema\Organization', ]; diff --git a/src/Validation/SchemaRules/Book.php b/src/Validation/SchemaRules/Book.php new file mode 100644 index 0000000..5066ab1 --- /dev/null +++ b/src/Validation/SchemaRules/Book.php @@ -0,0 +1,27 @@ + [ + 'type' => '@Person|@Organization|array', + 'item_type' => '@Person|@Organization', + 'required' => true, + ], + 'name' => [ + 'type' => 'string', + 'required' => true, + ], + 'url' => [ + 'type' => 'url', + 'required' => true, + ], + 'workExample' => [ + 'type' => 'array|@Book', + 'item_type' => '@Book', + ], + 'sameAs' => 'url', + 'bookFormat' => 'string', + 'inLanguage' => 'string', + 'isbn' => 'string', + 'datePublished' => 'iso_date', + 'identifier' => 'string', +]; diff --git a/src/Validation/SchemaRules/Brand.php b/src/Validation/SchemaRules/Brand.php deleted file mode 100644 index b9a312b..0000000 --- a/src/Validation/SchemaRules/Brand.php +++ /dev/null @@ -1,7 +0,0 @@ - 'string', - 'logo' => 'string', - 'url' => 'url', -]; diff --git a/src/Validation/SchemaRules/BreadcrumbList.php b/src/Validation/SchemaRules/BreadcrumbList.php index d08a073..3380187 100644 --- a/src/Validation/SchemaRules/BreadcrumbList.php +++ b/src/Validation/SchemaRules/BreadcrumbList.php @@ -2,8 +2,8 @@ return [ 'itemListElement' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\Intangible\ListItem', + 'type' => 'array|@ListItem', + 'item_type' => '@ListItem', 'required' => true, ], ]; diff --git a/src/Validation/SchemaRules/ClaimReview.php b/src/Validation/SchemaRules/ClaimReview.php new file mode 100644 index 0000000..d8e9379 --- /dev/null +++ b/src/Validation/SchemaRules/ClaimReview.php @@ -0,0 +1,18 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'reviewRating' => [ + 'type' => '@Rating', + 'required' => true, + ], + 'url' => [ + 'type' => 'url', + 'required' => true, + ], + 'author' => '@Organization|@Person', + 'itemReviewed' => '@Thing', +]; diff --git a/src/Validation/SchemaRules/ContactPoint.php b/src/Validation/SchemaRules/ContactPoint.php deleted file mode 100644 index 3796557..0000000 --- a/src/Validation/SchemaRules/ContactPoint.php +++ /dev/null @@ -1,7 +0,0 @@ - 'string', - 'email' => 'email', - 'contactType' => 'string', -]; diff --git a/src/Validation/SchemaRules/Course.php b/src/Validation/SchemaRules/Course.php index f2a45ab..250019c 100644 --- a/src/Validation/SchemaRules/Course.php +++ b/src/Validation/SchemaRules/Course.php @@ -1,7 +1,28 @@ 'string', + 'name' => [ + 'type' => 'string', + 'required' => true, + ], + 'url' => [ + 'type' => 'url', + 'required' => true, + ], 'description' => 'string', - 'provider' => '\Melbahja\Seo\Schema\Organization|\Melbahja\Seo\Schema\Person', + 'provider' => [ + 'type' => 'array|@Organization|@Person', + 'item_type' => '@Organization|@Person', + ], + 'courseMode' => [ + 'type' => 'array|string', + 'item_type' => 'string', + ], + 'coursePrerequisites' => 'string', + 'educationalLevel' => 'string', + 'teaches' => [ + 'type' => 'array|string', + 'item_type' => 'string', + ], + 'timeRequired' => 'string', ]; diff --git a/src/Validation/SchemaRules/DataFeed.php b/src/Validation/SchemaRules/DataFeed.php new file mode 100644 index 0000000..9b98bea --- /dev/null +++ b/src/Validation/SchemaRules/DataFeed.php @@ -0,0 +1,14 @@ + [ + 'type' => 'array|@DataFeedItem', + 'item_type' => '@DataFeedItem', + 'rules' => [ + 'dateModified' => 'iso_date', + 'item' => '@Thing', + ], + ], + 'dateModified' => 'iso_date', + 'name' => 'string', +]; diff --git a/src/Validation/SchemaRules/Dataset.php b/src/Validation/SchemaRules/Dataset.php index b4dbd06..7ed0189 100644 --- a/src/Validation/SchemaRules/Dataset.php +++ b/src/Validation/SchemaRules/Dataset.php @@ -1,27 +1,42 @@ [ + 'description' => [ 'type' => 'string', 'required' => true, ], - 'description' => [ + 'name' => [ 'type' => 'string', 'required' => true, ], - 'creator' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', - 'license' => 'string', - 'url' => 'url', - 'identifier' => 'string', - 'keywords' => [ - 'type' => 'string|array', - 'item_type' => 'string', + 'alternateName' => 'string', + 'creator' => [ + 'type' => '@Person|@Organization|array', + 'item_type' => '@Person|@Organization', + ], + 'citation' => 'string|@CreativeWork', + 'funder' => [ + 'type' => '@Person|@Organization|array', + 'item_type' => '@Person|@Organization', ], - 'variableMeasured' => [ - 'type' => 'string|array', - 'item_type' => 'string', + 'hasPart' => [ + 'type' => 'array|@Dataset', + 'item_type' => '@Dataset', ], + 'isPartOf' => 'url|@Dataset', + 'identifier' => 'url|string|@Thing', + 'isAccessibleForFree' => 'bool', + 'keywords' => 'string', + 'license' => 'url|@CreativeWork', + 'measurementTechnique' => 'string|url', + 'sameAs' => 'url', + 'spatialCoverage' => 'string|@Place', 'temporalCoverage' => 'string', - 'spatialCoverage' => '\Melbahja\Seo\Schema\Place|string', - 'version' => 'string', -]; + 'variableMeasured' => 'string|@Thing', + 'version' => 'string|int|float', + 'url' => 'url', + 'distribution' => [ + 'type' => 'array|@Thing', + 'item_type' => '@Thing', + ], +]; \ No newline at end of file diff --git a/src/Validation/SchemaRules/DiscussionForumPosting.php b/src/Validation/SchemaRules/DiscussionForumPosting.php new file mode 100644 index 0000000..5a0f380 --- /dev/null +++ b/src/Validation/SchemaRules/DiscussionForumPosting.php @@ -0,0 +1,26 @@ + [ + 'type' => '@Person|@Organization', + 'required' => true, + ], + 'datePublished' => [ + 'type' => 'string', + 'required' => true, + ], + 'text' => 'string', + 'image' => 'url|@ImageObject', + 'video' => '@VideoObject', + 'headline' => 'string', + 'comment' => [ + 'type' => 'array|@Thing', + 'item_type' => '@Thing', + ], + 'interactionStatistic' => '@Thing', + 'url' => 'url', + 'dateModified' => 'string', + 'creativeWorkStatus' => 'string', + 'isPartOf' => 'url|@Thing', + 'sharedContent' => '@Thing', +]; diff --git a/src/Validation/SchemaRules/EmployerAggregateRating.php b/src/Validation/SchemaRules/EmployerAggregateRating.php index 2eaa575..77d53b0 100644 --- a/src/Validation/SchemaRules/EmployerAggregateRating.php +++ b/src/Validation/SchemaRules/EmployerAggregateRating.php @@ -2,21 +2,18 @@ return [ 'itemReviewed' => [ - 'type' => '\Melbahja\Seo\Schema\Organization', + 'type' => '@Organization', 'required' => true, ], 'ratingValue' => [ - 'type' => 'float', + 'type' => 'string|int|float', 'required' => true, ], 'ratingCount' => [ 'type' => 'int', 'required' => true, ], - 'reviewCount' => [ - 'type' => 'int', - 'required' => true, - ], - 'bestRating' => 'float', - 'worstRating' => 'float', + 'reviewCount' => 'int', + 'bestRating' => 'int', + 'worstRating' => 'int', ]; diff --git a/src/Validation/SchemaRules/Event.php b/src/Validation/SchemaRules/Event.php index 0e7741f..c2a73f5 100644 --- a/src/Validation/SchemaRules/Event.php +++ b/src/Validation/SchemaRules/Event.php @@ -1,36 +1,27 @@ [ + 'type' => '@Place|@Thing', + 'required' => true, + ], 'name' => [ 'type' => 'string', 'required' => true, ], 'startDate' => [ - 'type' => 'iso_date', - 'required' => true, - ], - 'location' => [ - 'type' => '\Melbahja\Seo\Schema\Place|\Melbahja\Seo\Schema\Intangible\VirtualLocation', + 'type' => 'string', 'required' => true, ], - 'endDate' => 'iso_date', - 'eventStatus' => 'string', - 'eventAttendanceMode' => 'string', 'description' => 'string', - 'offers' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\Offer|array', - 'item_type' => '\Melbahja\Seo\Schema\Intangible\Offer', - ], - 'performer' => [ - 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization|array', - 'item_type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', - ], - 'organizer' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'endDate' => 'string', + 'eventStatus' => 'string', 'image' => [ - 'type' => 'string|array', - 'item_type' => 'string', + 'type' => 'array|url|@ImageObject', + 'item_type' => 'url|@ImageObject', ], -]; + 'offers' => '@Thing', + 'organizer' => '@Organization|@Person', + 'performer' => '@Person|@Organization', + 'previousStartDate' => 'string', +]; \ No newline at end of file diff --git a/src/Validation/SchemaRules/FAQPage.php b/src/Validation/SchemaRules/FAQPage.php index 0a99c09..8fc4482 100644 --- a/src/Validation/SchemaRules/FAQPage.php +++ b/src/Validation/SchemaRules/FAQPage.php @@ -2,8 +2,8 @@ return [ 'mainEntity' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Question', + 'type' => 'array|@Thing', + 'item_type' => '@Thing', 'required' => true, ], ]; diff --git a/src/Validation/SchemaRules/GeoCoordinates.php b/src/Validation/SchemaRules/GeoCoordinates.php deleted file mode 100644 index 192e9aa..0000000 --- a/src/Validation/SchemaRules/GeoCoordinates.php +++ /dev/null @@ -1,7 +0,0 @@ - 'float', - 'longitude' => 'float', -]; diff --git a/src/Validation/SchemaRules/Hotel.php b/src/Validation/SchemaRules/Hotel.php deleted file mode 100644 index 5720fe3..0000000 --- a/src/Validation/SchemaRules/Hotel.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - 'type' => 'string', - 'required' => true, - ], - 'address' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', - 'required' => true, - ], - 'amenityFeature' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\Intangible\LocationFeatureSpecification', - ], - 'checkinTime' => 'string', - 'checkoutTime' => 'string', -]; \ No newline at end of file diff --git a/src/Validation/SchemaRules/HowTo.php b/src/Validation/SchemaRules/HowTo.php deleted file mode 100644 index 9f701b0..0000000 --- a/src/Validation/SchemaRules/HowTo.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - 'type' => 'string', - 'required' => true, - ], - 'step' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\HowToStep|\Melbahja\Seo\Schema\CreativeWork\HowToSection', - 'required' => true, - ], - 'image' => [ - 'type' => 'string|array', - 'item_type' => 'string', - ], - 'totalTime' => 'string', - 'description' => 'string', -]; diff --git a/src/Validation/SchemaRules/HowToSection.php b/src/Validation/SchemaRules/HowToSection.php deleted file mode 100644 index a5421be..0000000 --- a/src/Validation/SchemaRules/HowToSection.php +++ /dev/null @@ -1,13 +0,0 @@ - [ - 'type' => 'string', - 'required' => true, - ], - 'itemListElement' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\HowToStep|\Melbahja\Seo\Schema\CreativeWork\HowToSection', - 'required' => true, - ], -]; diff --git a/src/Validation/SchemaRules/HowToStep.php b/src/Validation/SchemaRules/HowToStep.php deleted file mode 100644 index b21e98e..0000000 --- a/src/Validation/SchemaRules/HowToStep.php +++ /dev/null @@ -1,14 +0,0 @@ - [ - 'type' => 'string', - 'required' => true, - ], - 'name' => 'string', - 'url' => 'url', - 'image' => [ - 'type' => 'string|array', - 'item_type' => 'string', - ], -]; diff --git a/src/Validation/SchemaRules/ImageObject.php b/src/Validation/SchemaRules/ImageObject.php index a37e185..353e323 100644 --- a/src/Validation/SchemaRules/ImageObject.php +++ b/src/Validation/SchemaRules/ImageObject.php @@ -1,16 +1,10 @@ [ - 'type' => 'string', - 'required' => true, - ], - 'license' => 'url', - 'acquireLicensePage' => 'url', - 'creator' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', - 'creditText' => 'string', - 'copyrightNotice' => 'string', - 'name' => 'string', + 'url' => 'url', + 'caption' => 'string', + 'contentUrl' => 'url', + 'thumbnail' => 'url', + 'width' => 'int', + 'height' => 'int', ]; diff --git a/src/Validation/SchemaRules/ItemList.php b/src/Validation/SchemaRules/ItemList.php index d08a073..3380187 100644 --- a/src/Validation/SchemaRules/ItemList.php +++ b/src/Validation/SchemaRules/ItemList.php @@ -2,8 +2,8 @@ return [ 'itemListElement' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\Intangible\ListItem', + 'type' => 'array|@ListItem', + 'item_type' => '@ListItem', 'required' => true, ], ]; diff --git a/src/Validation/SchemaRules/JobPosting.php b/src/Validation/SchemaRules/JobPosting.php index 637718a..5a80792 100644 --- a/src/Validation/SchemaRules/JobPosting.php +++ b/src/Validation/SchemaRules/JobPosting.php @@ -1,38 +1,14 @@ [ - 'type' => 'string', - 'required' => true, - ], - 'description' => [ - 'type' => 'string', - 'required' => true, - ], - 'datePosted' => [ - 'type' => 'iso_date', - 'required' => true, - ], - 'hiringOrganization' => [ - 'type' => '\Melbahja\Seo\Schema\Organization', - 'required' => true, - ], - 'jobLocation' => [ - 'type' => '\Melbahja\Seo\Schema\Place|array', - 'item_type' => '\Melbahja\Seo\Schema\Place', - 'required' => true, - ], - 'employmentType' => [ - 'type' => 'string|array', - 'item_type' => 'string', - ], - 'validThrough' => 'iso_date', - 'baseSalary' => '\Melbahja\Seo\Schema\Intangible\MonetaryAmount', - 'applicantLocationRequirements' => [ - 'type' => '\Melbahja\Seo\Schema\Place\Country|array', - 'item_type' => '\Melbahja\Seo\Schema\Place\Country', - ], - 'jobLocationType' => 'string', + 'title' => 'string', + 'datePosted' => 'iso_date', + 'description' => 'string', + 'hiringOrganization' => '@Organization', + 'jobLocation' => '@Place', + 'baseSalary' => '@Thing', 'directApply' => 'bool', + 'identifier' => '@Thing', + 'jobLocationType' => 'string', + 'validThrough' => 'iso_date', ]; diff --git a/src/Validation/SchemaRules/ListItem.php b/src/Validation/SchemaRules/ListItem.php index 4051bcb..37d5b6f 100644 --- a/src/Validation/SchemaRules/ListItem.php +++ b/src/Validation/SchemaRules/ListItem.php @@ -1,16 +1,7 @@ [ - 'type' => 'string', - 'required' => true, - ], - 'item' => [ - 'type' => 'string', - 'required' => true, - ], - 'position' => [ - 'type' => 'int', - 'required' => true, - ], + 'item' => 'url|@Thing', + 'name' => 'string', + 'position' => 'int', ]; diff --git a/src/Validation/SchemaRules/LocalBusiness.php b/src/Validation/SchemaRules/LocalBusiness.php index c6136b8..f6d0276 100644 --- a/src/Validation/SchemaRules/LocalBusiness.php +++ b/src/Validation/SchemaRules/LocalBusiness.php @@ -1,33 +1,39 @@ [ 'type' => 'string', 'required' => true, ], 'address' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'type' => '@PostalAddress', 'required' => true, ], - 'url' => 'url', 'telephone' => 'string', 'priceRange' => 'string', 'openingHoursSpecification' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\Intangible\OpeningHoursSpecification', + 'type' => 'array|@Thing', + 'item_type' => '@Thing', + 'rules' => [ + 'dayOfWeek' => [ + 'type' => 'string|array', + 'item_type' => 'string', + ], + 'opens' => 'string', + 'closes' => 'string', + ], ], - 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', - 'review' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Review', + 'geo' => [ + 'type' => '@Thing', + 'rules' => [ + 'latitude' => 'float|int', + 'longitude' => 'float|int', + ], ], - 'geo' => '\Melbahja\Seo\Schema\Intangible\GeoCoordinates', - 'servesCuisine' => [ - 'type' => 'string|array', - 'item_type' => 'string', + 'url' => 'url', + 'sameAs' => [ + 'type' => 'array|url', + 'item_type' => 'url', ], + 'image' => 'url|@ImageObject', ]; diff --git a/src/Validation/SchemaRules/MathSolver.php b/src/Validation/SchemaRules/MathSolver.php new file mode 100644 index 0000000..f3eba4e --- /dev/null +++ b/src/Validation/SchemaRules/MathSolver.php @@ -0,0 +1,13 @@ + [ + 'type' => 'array|@Thing', + 'item_type' => '@Thing', + ], + 'url' => 'url', + 'usageInfo' => 'url', + 'inLanguage' => 'string|array', + 'assesses' => 'string|array', + 'learningResourceType' => 'string', +]; diff --git a/src/Validation/SchemaRules/MediaObject.php b/src/Validation/SchemaRules/MediaObject.php deleted file mode 100644 index f57ec1e..0000000 --- a/src/Validation/SchemaRules/MediaObject.php +++ /dev/null @@ -1,9 +0,0 @@ - 'url', - 'duration' => 'string', - 'encodingFormat' => 'string', - 'height' => 'int', - 'width' => 'int', -]; diff --git a/src/Validation/SchemaRules/MonetaryAmount.php b/src/Validation/SchemaRules/MonetaryAmount.php deleted file mode 100644 index 86b8a1c..0000000 --- a/src/Validation/SchemaRules/MonetaryAmount.php +++ /dev/null @@ -1,8 +0,0 @@ - 'string', - 'value' => 'float|int', - 'minValue' => 'float|int', - 'maxValue' => 'float|int', -]; diff --git a/src/Validation/SchemaRules/Movie.php b/src/Validation/SchemaRules/Movie.php new file mode 100644 index 0000000..fe8e4e0 --- /dev/null +++ b/src/Validation/SchemaRules/Movie.php @@ -0,0 +1,23 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'url' => [ + 'type' => 'url', + 'required' => true, + ], + 'image' => [ + 'type' => 'array|url|@ImageObject', + 'item_type' => 'url|@ImageObject', + ], + 'dateCreated' => 'iso_date', + 'director' => [ + 'type' => 'array|@Person', + 'item_type' => '@Person', + ], + 'review' => '@Review', + 'aggregateRating' => '@AggregateRating', +]; diff --git a/src/Validation/SchemaRules/Offer.php b/src/Validation/SchemaRules/Offer.php deleted file mode 100644 index b5a17ba..0000000 --- a/src/Validation/SchemaRules/Offer.php +++ /dev/null @@ -1,10 +0,0 @@ - 'float|string', - 'priceCurrency' => 'string', - 'availability' => 'string', - 'priceValidUntil' => 'iso_date', - 'url' => 'url', - 'itemCondition' => 'string', -]; diff --git a/src/Validation/SchemaRules/OnlineStore.php b/src/Validation/SchemaRules/OnlineStore.php new file mode 100644 index 0000000..56b65d9 --- /dev/null +++ b/src/Validation/SchemaRules/OnlineStore.php @@ -0,0 +1,3 @@ + 'string', - 'closes' => 'string', - 'dayOfWeek' => [ - 'type' => 'string|array', - 'item_type' => 'string', - ], - 'validFrom' => 'iso_date', - 'validThrough' => 'iso_date', -]; diff --git a/src/Validation/SchemaRules/Organization.php b/src/Validation/SchemaRules/Organization.php index 0c4689e..6155b19 100644 --- a/src/Validation/SchemaRules/Organization.php +++ b/src/Validation/SchemaRules/Organization.php @@ -1,14 +1,13 @@ 'string', 'url' => 'url', 'logo' => 'string', - 'address' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'address' => '@PostalAddress', 'contactPoint' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\ContactPoint|array', - 'item_type' => '\Melbahja\Seo\Schema\Intangible\ContactPoint', + 'type' => 'array|@Thing', + 'item_type' => '@Thing', ], 'sameAs' => [ 'type' => 'string|array', @@ -18,5 +17,5 @@ 'email' => 'email', 'telephone' => 'string', 'foundingDate' => 'iso_date', - 'numberOfEmployees' => '\Melbahja\Seo\Schema\Intangible\QuantitativeValue', + 'numberOfEmployees' => '@Thing', ]; diff --git a/src/Validation/SchemaRules/Person.php b/src/Validation/SchemaRules/Person.php index 33a8c7d..265bfe6 100644 --- a/src/Validation/SchemaRules/Person.php +++ b/src/Validation/SchemaRules/Person.php @@ -6,7 +6,7 @@ 'required' => true, ], 'url' => 'url', - 'image' => 'string', + 'image' => 'string|@ImageObject', 'sameAs' => [ 'type' => 'string|array', 'item_type' => 'string', @@ -14,3 +14,4 @@ 'description' => 'string', 'jobTitle' => 'string', ]; + diff --git a/src/Validation/SchemaRules/Place.php b/src/Validation/SchemaRules/Place.php new file mode 100644 index 0000000..ad0b270 --- /dev/null +++ b/src/Validation/SchemaRules/Place.php @@ -0,0 +1,22 @@ + 'string', + 'address' => [ + 'type' => '@Thing', + 'rules' => [ + 'streetAddress' => 'string', + 'addressLocality' => 'string', + 'addressRegion' => 'string', + 'postalCode' => 'string', + 'addressCountry' => 'string', + ], + ], + 'geo' => [ + 'type' => '@Thing', + 'rules' => [ + 'latitude' => 'float|int', + 'longitude' => 'float|int', + ], + ], +]; diff --git a/src/Validation/SchemaRules/PlaceRule.php b/src/Validation/SchemaRules/PlaceRule.php deleted file mode 100644 index 227d11b..0000000 --- a/src/Validation/SchemaRules/PlaceRule.php +++ /dev/null @@ -1,12 +0,0 @@ - 'string', - 'address' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', - 'geo' => '\Melbahja\Seo\Schema\Intangible\GeoCoordinates', - 'telephone' => 'string', - 'image' => [ - 'type' => 'string|array', - 'item_type' => 'string', - ], -]; diff --git a/src/Validation/SchemaRules/Product.php b/src/Validation/SchemaRules/Product.php index aaf2106..7d71ac2 100644 --- a/src/Validation/SchemaRules/Product.php +++ b/src/Validation/SchemaRules/Product.php @@ -1,27 +1,32 @@ 'url', 'name' => [ 'type' => 'string', 'required' => true, ], + 'aggregateRating' => '@AggregateRating', + 'offers' => '@Thing', + 'review' => '@Review', 'image' => [ - 'type' => 'string|array', - 'item_type' => 'string', - 'required' => true, + 'type' => 'array|url|@ImageObject', + 'item_type' => 'url|@ImageObject', ], 'description' => 'string', - 'brand' => '\Melbahja\Seo\Schema\Intangible\Brand|\Melbahja\Seo\Schema\Organization', - 'offers' => '\Melbahja\Seo\Schema\Intangible\Offer|\Melbahja\Seo\Schema\Intangible\AggregateOffer', - 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', - 'review' => [ - 'type' => '\Melbahja\Seo\Schema\CreativeWork\Review|array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Review', - ], 'sku' => 'string', 'mpn' => 'string', - 'gtin' => 'string', + 'brand' => '@Organization', + 'color' => 'string', + 'size' => 'string', + 'material' => 'string', + 'pattern' => 'string', + 'gtin14' => 'string', + 'itemCondition' => 'string', + 'availability' => 'string', + 'price' => 'float|int', + 'priceCurrency' => 'string', + 'priceValidUntil' => 'iso_date', + 'isVariantOf' => '@ProductGroup', + 'inProductGroupWithID' => 'string', ]; diff --git a/src/Validation/SchemaRules/ProductGroup.php b/src/Validation/SchemaRules/ProductGroup.php new file mode 100644 index 0000000..c5e9a1b --- /dev/null +++ b/src/Validation/SchemaRules/ProductGroup.php @@ -0,0 +1,22 @@ + [ + 'type' => 'string', + 'required' => true, + ], + 'productGroupID' => 'string', + 'variesBy' => [ + 'type' => 'array|string', + 'item_type' => 'string', + ], + 'hasVariant' => [ + 'type' => 'array|@Product', + 'item_type' => '@Product', + ], + 'aggregateRating' => '@AggregateRating', + 'brand' => '@Organization', + 'description' => 'string', + 'review' => '@Review', + 'url' => 'url', +]; diff --git a/src/Validation/SchemaRules/ProfilePage.php b/src/Validation/SchemaRules/ProfilePage.php index b5e0c43..0afb7ee 100644 --- a/src/Validation/SchemaRules/ProfilePage.php +++ b/src/Validation/SchemaRules/ProfilePage.php @@ -2,7 +2,7 @@ return [ 'mainEntity' => [ - 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'type' => '@Person|@Organization', 'required' => true, ], 'dateCreated' => 'iso_date', diff --git a/src/Validation/SchemaRules/QAPage.php b/src/Validation/SchemaRules/QAPage.php index 5488788..70d75b5 100644 --- a/src/Validation/SchemaRules/QAPage.php +++ b/src/Validation/SchemaRules/QAPage.php @@ -2,7 +2,7 @@ return [ 'mainEntity' => [ - 'type' => '\Melbahja\Seo\Schema\CreativeWork\Question', + 'type' => '@Thing', 'required' => true, ], ]; diff --git a/src/Validation/SchemaRules/Question.php b/src/Validation/SchemaRules/Question.php deleted file mode 100644 index f69d07b..0000000 --- a/src/Validation/SchemaRules/Question.php +++ /dev/null @@ -1,23 +0,0 @@ - [ - 'type' => 'string', - 'required' => true, - ], - 'answerCount' => [ - 'type' => 'int', - 'required' => true, - ], - 'acceptedAnswer' => [ - 'type' => '\Melbahja\Seo\Schema\CreativeWork\Answer|array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Answer', - ], - 'suggestedAnswer' => [ - 'type' => '\Melbahja\Seo\Schema\CreativeWork\Answer|array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Answer', - ], - 'text' => 'string', - 'datePublished' => 'iso_date', - 'author' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', -]; diff --git a/src/Validation/SchemaRules/Quiz.php b/src/Validation/SchemaRules/Quiz.php new file mode 100644 index 0000000..84bbc58 --- /dev/null +++ b/src/Validation/SchemaRules/Quiz.php @@ -0,0 +1,14 @@ + [ + 'type' => 'array|@Thing', + 'item_type' => '@Thing', + 'required' => true, + ], + 'about' => '@Thing', + 'educationalAlignment' => [ + 'type' => 'array|@Thing', + 'item_type' => '@Thing', + ], +]; \ No newline at end of file diff --git a/src/Validation/SchemaRules/Rating.php b/src/Validation/SchemaRules/Rating.php index b53120b..c3947e9 100644 --- a/src/Validation/SchemaRules/Rating.php +++ b/src/Validation/SchemaRules/Rating.php @@ -1,10 +1,9 @@ [ - 'type' => 'float', - 'required' => true, - ], - 'bestRating' => 'float', - 'worstRating' => 'float', + 'alternateName' => 'string', + 'bestRating' => 'int', + 'name' => 'string', + 'ratingValue' => 'int', + 'worstRating' => 'int', ]; diff --git a/src/Validation/SchemaRules/Recipe.php b/src/Validation/SchemaRules/Recipe.php index a8b7db7..bd76293 100644 --- a/src/Validation/SchemaRules/Recipe.php +++ b/src/Validation/SchemaRules/Recipe.php @@ -6,27 +6,24 @@ 'required' => true, ], 'image' => [ - 'type' => 'string|array', - 'item_type' => 'string', + 'type' => 'array|url|@ImageObject', + 'item_type' => 'url|@ImageObject', 'required' => true, ], - 'author' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', - 'datePublished' => 'iso_date', - 'description' => 'string', - 'prepTime' => 'string', - 'cookTime' => 'string', - 'totalTime' => 'string', - 'recipeYield' => 'string', - 'recipeCategory' => 'string', - 'recipeCuisine' => 'string', 'recipeIngredient' => [ - 'type' => 'array', + 'type' => 'array|string', 'item_type' => 'string', + 'required' => true, ], 'recipeInstructions' => [ - 'type' => 'array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\HowToStep|\Melbahja\Seo\Schema\CreativeWork\HowToSection', + 'type' => 'array|string|@ItemList', + 'item_type' => 'string|@ItemList', + 'required' => true, ], - 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', - 'video' => '\Melbahja\Seo\Schema\CreativeWork\VideoObject', -]; + 'author' => '@Person|@Organization', + 'datePublished' => 'iso_date', + 'description' => 'string', + 'recipeYield' => 'string|int', + 'aggregateRating' => '@AggregateRating', + 'video' => '@VideoObject', +]; \ No newline at end of file diff --git a/src/Validation/SchemaRules/Restaurant.php b/src/Validation/SchemaRules/Restaurant.php index b59b5be..085db4b 100644 --- a/src/Validation/SchemaRules/Restaurant.php +++ b/src/Validation/SchemaRules/Restaurant.php @@ -5,14 +5,16 @@ 'type' => 'string', 'required' => true, ], - 'address' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', + 'url' => [ + 'type' => 'url', 'required' => true, ], - 'servesCuisine' => [ - 'type' => 'string|array', - 'item_type' => 'string', + 'image' => [ + 'type' => 'array|url|@ImageObject', + 'item_type' => 'url|@ImageObject', ], - 'menu' => 'string', - 'acceptsReservations' => 'bool', + 'telephone' => 'string', + 'priceRange' => 'string', + 'address' => '@PostalAddress', + 'aggregateRating' => '@AggregateRating', ]; diff --git a/src/Validation/SchemaRules/Review.php b/src/Validation/SchemaRules/Review.php index ee17565..f8695a1 100644 --- a/src/Validation/SchemaRules/Review.php +++ b/src/Validation/SchemaRules/Review.php @@ -1,14 +1,13 @@ [ - 'type' => '\Melbahja\Seo\Schema\Person|\Melbahja\Seo\Schema\Organization', + 'type' => '@Person|@Organization', 'required' => true, ], 'reviewRating' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\Rating', + 'type' => '\Melbahja\Seo\Schema\Thing', 'required' => true, ], 'itemReviewed' => '\Melbahja\Seo\Schema\Thing', diff --git a/src/Validation/SchemaRules/SoftwareApplication.php b/src/Validation/SchemaRules/SoftwareApplication.php index f8d191e..9049356 100644 --- a/src/Validation/SchemaRules/SoftwareApplication.php +++ b/src/Validation/SchemaRules/SoftwareApplication.php @@ -1,21 +1,19 @@ [ 'type' => 'string', 'required' => true, ], 'offers' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\Offer|\Melbahja\Seo\Schema\Intangible\AggregateOffer', + 'type' => '@Thing|array', + 'item_type' => '@Thing', 'required' => true, ], - 'aggregateRating' => '\Melbahja\Seo\Schema\Intangible\AggregateRating', + 'aggregateRating' => '@AggregateRating', 'review' => [ - 'type' => '\Melbahja\Seo\Schema\CreativeWork\Review|array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Review', + 'type' => '@Review|array', + 'item_type' => '@Review', ], 'applicationCategory' => 'string', 'operatingSystem' => 'string', diff --git a/src/Validation/SchemaRules/Store.php b/src/Validation/SchemaRules/Store.php deleted file mode 100644 index bf2ede3..0000000 --- a/src/Validation/SchemaRules/Store.php +++ /dev/null @@ -1,12 +0,0 @@ - [ - 'type' => 'string', - 'required' => true, - ], - 'address' => [ - 'type' => '\Melbahja\Seo\Schema\Intangible\PostalAddress', - 'required' => true, - ], -]; diff --git a/src/Validation/SchemaRules/VacationRental.php b/src/Validation/SchemaRules/VacationRental.php new file mode 100644 index 0000000..4c55eab --- /dev/null +++ b/src/Validation/SchemaRules/VacationRental.php @@ -0,0 +1,43 @@ + [ + 'type' => '@Thing', + 'required' => true, + ], + 'identifier' => [ + 'type' => 'string', + 'required' => true, + ], + 'image' => [ + 'type' => 'array|url|@ImageObject', + 'item_type' => 'url|@ImageObject', + 'required' => true, + ], + 'latitude' => [ + 'type' => 'float', + 'required' => true, + ], + 'longitude' => [ + 'type' => 'float', + 'required' => true, + ], + 'name' => [ + 'type' => 'string', + 'required' => true, + ], + 'address' => '@PostalAddress', + 'aggregateRating' => '@AggregateRating', + 'brand' => '@Organization', + 'checkinTime' => 'string', + 'checkoutTime' => 'string', + 'description' => 'string', + 'knowsLanguage' => [ + 'type' => 'array|string', + 'item_type' => 'string', + ], + 'review' => [ + 'type' => 'array|@Review', + 'item_type' => '@Review', + ], +]; diff --git a/src/Validation/SchemaRules/VideoObject.php b/src/Validation/SchemaRules/VideoObject.php index a937074..0c31499 100644 --- a/src/Validation/SchemaRules/VideoObject.php +++ b/src/Validation/SchemaRules/VideoObject.php @@ -1,28 +1,15 @@ [ - 'type' => 'string', - 'required' => true, - ], + 'name' => 'string', + 'description' => 'string', 'thumbnailUrl' => [ - 'type' => 'string|array', - 'item_type' => 'string', - 'required' => true, - ], - 'uploadDate' => [ - 'type' => 'iso_date', - 'required' => true, + 'type' => 'array|url', + 'item_type' => 'url', ], 'contentUrl' => 'url', - 'description' => 'string', - 'duration' => 'string', 'embedUrl' => 'url', + 'uploadDate' => 'iso_date', + 'duration' => 'string', 'expires' => 'iso_date', - 'hasPart' => [ - 'type' => '\Melbahja\Seo\Schema\CreativeWork\Clip|array', - 'item_type' => '\Melbahja\Seo\Schema\CreativeWork\Clip', - ], - 'publication' => '\Melbahja\Seo\Schema\CreativeWork\BroadcastEvent', ]; diff --git a/src/Validation/SchemaRules/VirtualLocation.php b/src/Validation/SchemaRules/VirtualLocation.php deleted file mode 100644 index 33345e0..0000000 --- a/src/Validation/SchemaRules/VirtualLocation.php +++ /dev/null @@ -1,5 +0,0 @@ - 'url', -]; diff --git a/src/Validation/SchemaRules/WebPage.php b/src/Validation/SchemaRules/WebPage.php deleted file mode 100644 index 02efe2e..0000000 --- a/src/Validation/SchemaRules/WebPage.php +++ /dev/null @@ -1,10 +0,0 @@ - 'string', - 'description' => 'string', - 'datePublished' => 'iso_date', - 'dateModified' => 'iso_date', - 'mainEntity' => '\Melbahja\Seo\Schema\Thing', - 'breadcrumb' => '\Melbahja\Seo\Schema\Intangible\BreadcrumbList', -]; From 580625177de1b603f3d69110b2471653dcf1a868 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:30:31 +0100 Subject: [PATCH 15/45] rewrite make schema validator handle more cases --- src/Validation/SchemaValidator.php | 331 ++++++++++++++++++++++------- 1 file changed, 257 insertions(+), 74 deletions(-) diff --git a/src/Validation/SchemaValidator.php b/src/Validation/SchemaValidator.php index e3a768f..4b57052 100644 --- a/src/Validation/SchemaValidator.php +++ b/src/Validation/SchemaValidator.php @@ -1,44 +1,174 @@ jsonSerialize(); + } elseif ($schema instanceof Schema) { + $graph = []; + foreach ($schema->all() as $thing) { + $graph[] = $thing->jsonSerialize(); + } + $schema = ['@graph' => $graph]; + } + + if (is_array($schema)) { + + // single entity + if (isset($schema['@type'])) { + return static::validateType($schema['@type'], $schema); + } + + // @graph structure + if (isset($schema['@graph'])) { + + $errors = []; + if (!is_array($schema['@graph'])) { + return ['@graph must be an array']; + } + + $idRefs = []; + foreach ($schema['@graph'] as $k => $node) + { + if ($node instanceof Thing) { + $node = $node->jsonSerialize(); + $schema['@graph'][$k] = $node; + } + + // sub types refs + if (isset($node['@id']) && is_string($node['@id']) && count($node) > 1) { + $idRefs[$node['@id']] = $node; + } + } + + foreach ($schema['@graph'] as $index => $item) + { + if (!is_array($item)) { + $errors[] = "@graph[{$index}] must be an array"; + continue; + } + + // handle @id refs, allowing some that refs ids that are not defined like page refs. + if (isset($item['@id']) && count($item) === 1) { + // if (!isset($idRefs[$item['@id']])) { + // $errors[] = "@graph[{$index}].@id references an unknown node: {$item['@id']}"; + // } + continue; + } + + // merge props if @id ref exists + if (isset($item['@id']) && isset($idRefs[$item['@id']])) { + $item = array_merge($idRefs[$item['@id']], $item); + } + + if (!isset($item['@type'])) { + $errors[] = "@graph[{$index}] missing @type property"; + continue; + } + + if (($itemErrors = static::validateType($item['@type'], $item)) !== null) { + + foreach ($itemErrors as $error) + { + $errors[] = "@graph[{$index}].{$error}"; + } + } + } + return empty($errors) ? null : $errors; + } + + // array without @type or @graph + return ['Schema array must have @type property or @graph structure']; + } + + return ['Input must be a Schema, Thing, or array']; + } + + public static function validateType(string|array $schemaType, array $data): ?array + { + // handle one/multiple types like: ['Article', 'NewsArticle'] + $types = is_array($schemaType) ? $schemaType : [$schemaType]; + + $rules = []; + foreach ($types as $type) + { + $typeRules = self::loadRules($type); + $rules = array_merge($rules, $typeRules); + } + + $errors = []; + // validate existing rules. foreach ($rules as $prop => $rule) { $value = $data[$prop] ?? null; + $rule = is_string($rule) ? ['type' => $rule] : $rule; - if (is_string($rule)) { - $rule = ['type' => $rule]; - } - - // Check required if (!empty($rule['required']) && self::isEmpty($value)) { $errors[] = "{$prop} is required"; continue; } - // Skip if not required and empty + // skip if not required and empty if (empty($rule['required']) && self::isEmpty($value)) { continue; } - // Check type - if (isset($rule['type'])) { - $typeErrors = self::validateTypeValue($prop, $value, $rule['type'], $rule); - if ($typeErrors !== null) { - $errors = array_merge($errors, $typeErrors); + // validate prop + if (isset($rule['type']) && ($propErrors = self::validateProp($prop, $value, $rule['type'], $rule)) !== null ) { + $errors = array_merge($errors, $propErrors); + } + } + + // validate nested props/objects that not in rules + foreach ($data as $prop => $value) + { + + if (in_array($prop, ['@type', '@context', '@id']) || isset($rules[$prop])) { + continue; + } + + // skip @id only refs + if (is_array($value) && isset($value['@id']) && count($value) === 1) { + continue; + } + + // recursively validate + if (is_array($value) && isset($value['@type'])) { + + if (($nErrors = self::validateType($value['@type'], $value)) !== null) { + foreach ($nErrors as $error) { + $errors[] = "{$prop}.{$error}"; + } + } + + } elseif (is_array($value)) { + + // in case of array of objects + foreach ($value as $index => $item) + { + if (is_array($item) && isset($item['@type'])) { + + if ( ($nErrors = self::validateType($item['@type'], $item)) !== null) { + foreach ($nErrors as $error) + { + $errors[] = "{$prop}[{$index}].{$error}"; + } + } + } } } } @@ -46,56 +176,122 @@ public static function validate(string $schemaType, array $data): ?array return empty($errors) ? null : $errors; } - private static function validateTypeValue(string $prop, $value, string $type, array $rule): ?array + private static function validateProp(string $prop, $value, string $type, array $rule): ?array { $errors = []; - // Handle union types (string|array) - if (strpos($type, '|') !== false) - { + // union types (string|array|type thing) + if (str_contains($type, '|')) { + $typeMatched = false; + $matchedType = null; foreach (explode('|', $type) as $singleType) { - $singleType = trim($singleType); - $typeErrors = self::checkType($value, $singleType, $rule); - - if ($typeErrors === null) { + if (self::checkType($value, trim($singleType), $rule) === null) { $typeMatched = true; + $matchedType = trim($singleType); break; } } - if (!$typeMatched) { + if ($typeMatched === false) { return ["{$prop} must be one of: {$type}"]; } - return null; + // validate single item type from array + if ($matchedType === 'array' && isset($rule['item_type']) && is_array($value)) { + + foreach ($value as $index => $item) + { + if ( ($nErrors = self::checkType($item, $rule['item_type'], [])) !== null) { + foreach ($nErrors as $error) + { + $errors[] = "{$prop}[{$index}] {$error}"; + } + } + } + } + + return empty($errors) ? null : $errors; } - // Single type check - $typeErrors = self::checkType($value, $type, $rule); - if ($typeErrors !== null) { + // if no union it's a single type! + if ( ($nErrors = self::checkType($value, $type, $rule)) !== null) { - foreach ($typeErrors as $error) + foreach ($nErrors as $error) { - // For nested errors, prepend the property name - if (strpos($error, '.') !== false) { - $errors[] = "{$prop}.{$error}"; - } else { - $errors[] = "{$prop}: {$error}"; - } + $errors[] = "{$prop}: {$error}"; } return $errors; } - return null; + // array items if type is array + if ($type === 'array' && isset($rule['item_type']) && is_array($value)) { + + foreach ($value as $index => $item) + { + if ( ($nErrors = self::checkType($item, $rule['item_type'], [])) !== null) { + foreach ($nErrors as $error) + { + $errors[] = "{$prop}[{$index}] {$error}"; + } + } + } + } + + return empty($errors) ? null : $errors; } private static function checkType($value, string $type, array $rule): ?array { $errors = []; + // @ prefixed types, rule file references + if (str_starts_with($type, '@')) { + + $ruleName = substr($type, 1); + $baseRules = self::loadRules($ruleName); + + // merge rules with inline rules, inline rules > file rules. + if (isset($rule['rules']) && is_array($rule['rules'])) { + $baseRules = array_merge($baseRules, $rule['rules']); + } + + // TODO: maybe here we need just to return null when no rules are defined? + if (empty($baseRules)) { + return ["no rules found for @{$ruleName}"]; + } + + // value must be an array or Thing instance + if ($value instanceof Thing) { + + $value = $value->jsonSerialize(); + if ($value['@type'] !== $ruleName && $value['@type'] !== "Thing") { + return ["expected @type '{$ruleName}', got '{$value['@type']}'"]; + } + + } elseif (!is_array($value)) { + + return ["must be an array or Thing instance"]; + } + + // skip @id only references + if (isset($value['@id']) && count($value) === 1) { + return null; + } + + // check @type matches + if (isset($value['@type']) && $value['@type'] !== $ruleName && $ruleName !== 'Thing') { + return ["expected @type '{$ruleName}', got '{$value['@type']}'"]; + } + + // recursive rules validation + $errors = array_merge($errors, self::validateType($ruleName, $value) ?? []); + + return empty($errors) ? null : $errors; + } + // Built-in types switch ($type) { @@ -107,40 +303,27 @@ private static function checkType($value, string $type, array $rule): ?array case 'int': case 'integer': - if (!is_int($value)) { + if (!is_int($value) && !is_numeric($value)) { $errors[] = "must be an integer"; } break; case 'float': - - if (!is_float($value)) { + if (!is_float($value) && !is_int($value) && !is_numeric($value)) { $errors[] = "must be a float"; } break; case 'bool': case 'boolean': - - if (!is_bool($value)) { + if (!is_bool($value) && $value != 'true' && $value != 'false') { $errors[] = "must be a boolean"; } break; case 'array': - if (!is_array($value)) { $errors[] = "must be an array"; - } elseif (isset($rule['array_item_type'])) { - // Check array items if specified - foreach ($value as $index => $item) { - $itemErrors = self::checkType($item, $rule['array_item_type'], []); - if ($itemErrors !== null) { - foreach ($itemErrors as $error) { - $errors[] = "[{$index}] {$error}"; - } - } - } } break; @@ -163,40 +346,35 @@ private static function checkType($value, string $type, array $rule): ?array break; default: - // Class type + + // class type if (!class_exists($type)) { $errors[] = "class {$type} does not exist"; break; } - // If value is instance of class - if ($value instanceof $type) { - // Check if class has a validate method - if (method_exists($value, 'validate')) { - $nestedErrors = $value->validate(); - if ($nestedErrors !== null) { - $errors = array_merge($errors, $nestedErrors); - } - } - break; + // handle Thing instances + if ($value instanceof Thing) { + $value = $value->jsonSerialize(); } - // If value is array, load and validate against class rules + // If value is array, validate against class rules if (is_array($value)) { - $className = self::getClassNameFromType($type); - $classRules = self::loadRules($className); - // If no rules for class, fallback to instance check - if (empty($classRules)) { - $errors[] = "must be an instance of {$type}"; + if (isset($value['@id']) && count($value) === 1) { break; } - // Recursively validate array against class rules - $nestedErrors = self::validate($className, $value); - if ($nestedErrors !== null) { - $errors = array_merge($errors, $nestedErrors); + $typeName = self::getClassNameFromType($type); + + // check if @type matches, only if it it's not a generic Thing + if (isset($value['@type']) && $value['@type'] !== $typeName && $value['@type'] !== 'Thing' && $typeName !== 'Thing') { + $errors[] = "expected @type '{$typeName}', got '{$value['@type']}'"; + break; } + + // recursive type validation + $errors = array_merge($errors, self::validateType($value['@type'] ?? $typeName, $value) ?? []); break; } @@ -207,8 +385,12 @@ private static function checkType($value, string $type, array $rule): ?array return empty($errors) ? null : $errors; } - private static function getClassNameFromType(string $type): string + private static function getClassNameFromType(string|Thing $type): string { + if (is_object($type)) { + return static::getClassNameFromType($type::class); + } + $parts = explode('\\', $type); return end($parts); } @@ -224,6 +406,7 @@ private static function loadRules(string $schemaType): array private static function isEmpty($value): bool { + if (is_array($value)) { return empty($value); } else if (is_string($value)) { From c8ac65a9aca166f7cbe9a9b87cbb09bf47d4f792 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:31:22 +0100 Subject: [PATCH 16/45] add supported and most used schema types --- .../{MediaObject.php => AggregateRating.php} | 8 ++--- src/Schema/CreativeWork/Article.php | 6 +--- src/Schema/CreativeWork/BlogPosting.php | 8 ++--- .../CreativeWork/{HowToStep.php => Book.php} | 9 ++---- src/Schema/CreativeWork/ClaimReview.php | 13 ++++++++ src/Schema/CreativeWork/Course.php | 6 +--- src/Schema/CreativeWork/DataFeed.php | 13 ++++++++ src/Schema/CreativeWork/Dataset.php | 6 +--- ...{Answer.php => DiscussionForumPosting.php} | 7 ++-- src/Schema/CreativeWork/FAQPage.php | 8 ++--- src/Schema/CreativeWork/HowTo.php | 6 +--- src/Schema/CreativeWork/HowToSection.php | 16 ---------- src/Schema/CreativeWork/ImageObject.php | 9 ++---- src/Schema/CreativeWork/LearningResource.php | 13 ++++++++ src/Schema/CreativeWork/MathSolver.php | 13 ++++++++ .../CreativeWork/{Question.php => Movie.php} | 7 ++-- src/Schema/CreativeWork/NewsArticle.php | 7 ++-- src/Schema/CreativeWork/ProfilePage.php | 7 ++-- src/Schema/CreativeWork/QAPage.php | 7 ++-- src/Schema/CreativeWork/Quiz.php | 13 ++++++++ src/Schema/CreativeWork/Recipe.php | 7 ++-- src/Schema/CreativeWork/Review.php | 6 +--- .../CreativeWork/SoftwareApplication.php | 6 +--- src/Schema/CreativeWork/VideoObject.php | 7 ++-- src/Schema/CreativeWork/WebPage.php | 6 +--- src/Schema/Event.php | 9 +----- src/Schema/Intangible.php | 1 - src/Schema/Intangible/AggregateOffer.php | 16 ---------- src/Schema/Intangible/AggregateRating.php | 16 ---------- src/Schema/Intangible/BreadcrumbList.php | 7 ++-- src/Schema/Intangible/ContactPoint.php | 17 ---------- .../Intangible/EmployerAggregateRating.php | 7 ++-- src/Schema/Intangible/GeoCoordinates.php | 17 ---------- src/Schema/Intangible/ItemList.php | 6 +--- src/Schema/Intangible/JobPosting.php | 6 +--- src/Schema/Intangible/ListItem.php | 17 ---------- src/Schema/Intangible/MonetaryAmount.php | 17 ---------- .../Intangible/{Brand.php => Occupation.php} | 8 ++--- src/Schema/Intangible/Offer.php | 17 ---------- .../Intangible/OpeningHoursSpecification.php | 17 ---------- src/Schema/Intangible/PostalAddress.php | 16 ---------- src/Schema/Intangible/Rating.php | 17 ---------- src/Schema/Intangible/VirtualLocation.php | 17 ---------- src/Schema/Organization.php | 9 +----- src/Schema/Organization/OnlineBusiness.php | 0 src/Schema/Organization/OnlineStore.php | 0 src/Schema/Person.php | 9 +----- src/Schema/Place.php | 9 +----- src/Schema/Place/Hotel.php | 6 +--- src/Schema/Place/LocalBusiness.php | 6 +--- src/Schema/Place/Restaurant.php | 6 +--- src/Schema/Place/Store.php | 13 ++++++++ src/Schema/Place/VacationRental.php | 13 ++++++++ src/Schema/Product.php | 9 +----- src/Schema/ProductGroup.php | 12 +++++++ src/Schema/Thing.php | 32 +++++++++++-------- 56 files changed, 172 insertions(+), 394 deletions(-) rename src/Schema/CreativeWork/{MediaObject.php => AggregateRating.php} (58%) rename src/Schema/CreativeWork/{HowToStep.php => Book.php} (55%) create mode 100644 src/Schema/CreativeWork/ClaimReview.php create mode 100644 src/Schema/CreativeWork/DataFeed.php rename src/Schema/CreativeWork/{Answer.php => DiscussionForumPosting.php} (65%) delete mode 100644 src/Schema/CreativeWork/HowToSection.php create mode 100644 src/Schema/CreativeWork/LearningResource.php create mode 100644 src/Schema/CreativeWork/MathSolver.php rename src/Schema/CreativeWork/{Question.php => Movie.php} (64%) create mode 100644 src/Schema/CreativeWork/Quiz.php delete mode 100644 src/Schema/Intangible/AggregateOffer.php delete mode 100644 src/Schema/Intangible/AggregateRating.php delete mode 100644 src/Schema/Intangible/ContactPoint.php delete mode 100644 src/Schema/Intangible/GeoCoordinates.php delete mode 100644 src/Schema/Intangible/ListItem.php delete mode 100644 src/Schema/Intangible/MonetaryAmount.php rename src/Schema/Intangible/{Brand.php => Occupation.php} (61%) delete mode 100644 src/Schema/Intangible/Offer.php delete mode 100644 src/Schema/Intangible/OpeningHoursSpecification.php delete mode 100644 src/Schema/Intangible/PostalAddress.php delete mode 100644 src/Schema/Intangible/Rating.php delete mode 100644 src/Schema/Intangible/VirtualLocation.php create mode 100644 src/Schema/Organization/OnlineBusiness.php create mode 100644 src/Schema/Organization/OnlineStore.php create mode 100644 src/Schema/Place/Store.php create mode 100644 src/Schema/Place/VacationRental.php create mode 100644 src/Schema/ProductGroup.php diff --git a/src/Schema/CreativeWork/MediaObject.php b/src/Schema/CreativeWork/AggregateRating.php similarity index 58% rename from src/Schema/CreativeWork/MediaObject.php rename to src/Schema/CreativeWork/AggregateRating.php index b8a97cb..7d2841c 100644 --- a/src/Schema/CreativeWork/MediaObject.php +++ b/src/Schema/CreativeWork/AggregateRating.php @@ -5,13 +5,9 @@ /** * @package Melbahja\Seo - * @since v2.0 * @see https://git.io/phpseo - * @see https://schema.org/MediaObject + * @see https://schema.org/AggregateRating * @license MIT * @copyright Mohamed Elabhja */ -class MediaObject extends CreativeWork -{ - protected array|string $type = "MediaObject"; -} +class AggregateRating extends CreativeWork { } diff --git a/src/Schema/CreativeWork/Article.php b/src/Schema/CreativeWork/Article.php index bbce4d9..a36583d 100644 --- a/src/Schema/CreativeWork/Article.php +++ b/src/Schema/CreativeWork/Article.php @@ -5,13 +5,9 @@ /** * @package Melbahja\Seo - * @since v2.0 * @see https://git.io/phpseo * @see https://schema.org/Article * @license MIT * @copyright Mohamed Elabhja */ -class Article extends CreativeWork -{ - protected array|string $type = "Article"; -} +class Article extends CreativeWork { } diff --git a/src/Schema/CreativeWork/BlogPosting.php b/src/Schema/CreativeWork/BlogPosting.php index a3632fa..c80c18d 100644 --- a/src/Schema/CreativeWork/BlogPosting.php +++ b/src/Schema/CreativeWork/BlogPosting.php @@ -2,15 +2,13 @@ namespace Melbahja\Seo\Schema\CreativeWork; +use Melbahja\Seo\Schema\CreativeWork; + /** * @package Melbahja\Seo - * @since v2.0 * @see https://git.io/phpseo * @see https://schema.org/BlogPosting * @license MIT * @copyright Mohamed Elabhja */ -class BlogPosting extends Article -{ - protected array|string $type = "BlogPosting"; -} +class BlogPosting extends CreativeWork { } diff --git a/src/Schema/CreativeWork/HowToStep.php b/src/Schema/CreativeWork/Book.php similarity index 55% rename from src/Schema/CreativeWork/HowToStep.php rename to src/Schema/CreativeWork/Book.php index 702867a..4ec9c05 100644 --- a/src/Schema/CreativeWork/HowToStep.php +++ b/src/Schema/CreativeWork/Book.php @@ -1,16 +1,13 @@ type = $type; - $this->props = $props; + public function __construct( + protected array $props = [], + protected string|array|null $type = null, + protected ?string $id = null, + protected ?string $context = null + ) { + + if ($this->id !== null) { + $this->props['@id'] = $this->id; + } + + if (empty($this->type)) { + $parts = explode("\\", static::class); + $this->type = end($parts); + } } public function __get(string $name) @@ -38,12 +43,11 @@ public function __set(string $name, $value) public function jsonSerialize(): array { - $data = [ + return array_merge($this->props, + [ '@type' => $this->type, '@context' => $this->context ?? "https://schema.org", - ]; - - return array_merge($this->props, $data); + ]); } public function __toString(): string From b7d58e61262ea50eed0d80b1e003c5d8255525ec Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:32:10 +0100 Subject: [PATCH 17/45] tests passed the schema validation rules --- tests/SchemaValidationTest.php | 330 ++++++++++++++++++++++++++++++++- 1 file changed, 325 insertions(+), 5 deletions(-) diff --git a/tests/SchemaValidationTest.php b/tests/SchemaValidationTest.php index bc71f1a..6c696c4 100644 --- a/tests/SchemaValidationTest.php +++ b/tests/SchemaValidationTest.php @@ -3,6 +3,11 @@ use PHPUnit\Framework\TestCase; use Melbahja\Seo\Validation\SchemaValidator; +use Melbahja\Seo\Schema; +use Melbahja\Seo\Schema\Thing; +use Melbahja\Seo\Schema\Intangible; +use Melbahja\Seo\Schema\CreativeWork; +use Melbahja\Seo\Schema\Organization; use Melbahja\Seo\Schema\Place\LocalBusiness; class SchemaValidationTest extends TestCase @@ -13,6 +18,7 @@ public function testLocalBusinessValidatorWithValidData() $biz = new LocalBusiness([ 'name' => 'Casablanca Cafe', 'address' => [ + '@type' => 'PostalAddress', 'streetAddress' => '123 Avenue Mohammed V', 'addressLocality' => 'Casablanca', 'addressRegion' => 'Casablanca-Settat', @@ -26,11 +32,325 @@ public function testLocalBusinessValidatorWithValidData() ]); - - $errors = SchemaValidator::validate('LocalBusiness', $biz->jsonSerialize()); + $errors = SchemaValidator::validate($biz); $this->assertNull($errors, 'LocalBusiness validation should pass with valid data'); } + public function testSchemaWithMultipleTypes() + { + $data = [ + '@type' => ['Article', 'NewsArticle'], + 'headline' => 'Breaking News', + 'datePublished' => '2024-01-15' + ]; + + $errors = SchemaValidator::validateType(['Article', 'NewsArticle'], $data); + $this->assertNull($errors); + + $schema = new CreativeWork(type: ['Article', 'NewsArticle'], props: [ + 'headline' => 'Breaking News', + 'datePublished' => '2024-01-15' + ]); + + $errors = SchemaValidator::validate($schema); + $this->assertNull($errors); + } + + public function testThingWithWrongType() + { + $data = [ + 'name' => 'Test', + 'address' => [ + '@type' => 'Rating', // Wrong! Should be PostalAddress + 'streetAddress' => '123 Main St' + ], + 'url' => 'https://example.com', + 'telephone' => '+212524111111' + ]; + + $errors = SchemaValidator::validateType('LocalBusiness', $data); + $this->assertIsArray($errors); + $this->assertStringContainsString("expected @type 'PostalAddress'", implode(' ', $errors)); + } + + public function testLocalBusinessWithInvalidOpeningHours() + { + $data = [ + 'name' => 'Test Restaurant', + 'address' => [ + '@type' => 'PostalAddress', + 'streetAddress' => '123 Main St', + 'addressLocality' => 'Casablanca', + 'addressRegion' => 'Casablanca-Settat', + 'postalCode' => '20000', + 'addressCountry' => 'MA' + ], + 'url' => 'https://example.com', + 'telephone' => '+212524111111', + 'openingHoursSpecification' => [ + [ + '@type' => 'OpeningHoursSpecification', + 'dayOfWeek' => 'Monday', + 'opens' => '09:00', + 'closes' => '18:00' + ], + [ + '@type' => 'OpeningHoursSpecification', + 'dayOfWeek' => 123, // Wrong! Should be string + 'opens' => 900, // Wrong! Should be string + 'closes' => true // Wrong! Should be string + ], + 'not an array' // Wrong! Should be array/object + ] + ]; + + $errors = SchemaValidator::validateType('LocalBusiness', $data); + $this->assertIsArray($errors); + $this->assertStringContainsString('openingHoursSpecification', implode(' ', $errors)); + } + + public function testThingObjectWithCorrectType() + { + $rating = new Thing(type: 'Rating', props: ['ratingValue' => 4.5]); + + $review = [ + '@type' => 'Review', + 'reviewRating' => $rating, // Thing object instead of Rating class + 'reviewBody' => 'Great!', + 'author' => ['@id' => 'https://example.com/authors/mohamed'], // ref only + ]; + + // Should pass since Thing has correct @type + $errors = SchemaValidator::validate($review); + $this->assertNull($errors); + } + + public function testNumericTypeFlexibility() + { + $rating1 = new Thing(type: 'Rating', props: ['ratingValue' => 5]); // int + $rating2 = new Thing(type: 'Rating', props: ['ratingValue' => 5.0]); // float + $rating3 = new Thing(type: 'Rating', props: ['ratingValue' => '5']); // string + + $this->assertNull(SchemaValidator::validate($rating1)); + $this->assertNull(SchemaValidator::validate($rating2)); + $this->assertNull(SchemaValidator::validate($rating3)); + } + + public function testArrayItemsValidation() + { + $data = [ + 'name' => 'Test Restaurant', + 'url' => 'https://example.com', + 'sameAs' => ['https://reddit.com/example', 123, 'https://github.com/example'], // 123 is invalid + ]; + + $errors = SchemaValidator::validateType('LocalBusiness', $data); + $this->assertIsArray($errors); + $this->assertStringContainsString('must be a valid URL', implode(' ', $errors)); + } + + public function testDeeplyNestedValidation() + { + $data = [ + '@type' => 'Review', + 'author' => [ + '@type' => 'Person', + 'name' => 'John', + 'address' => [ + '@type' => 'PostalAddress', + 'streetAddress' => 123 // Invalid: should be string + ] + ], + 'reviewBody' => 'Great!' + ]; + + $errors = SchemaValidator::validate($data); + $this->assertIsArray($errors); + + $thing = new Thing([ + '@type' => 'Review', + 'author' => [ + '@type' => 'Person', + 'name' => 'John', + 'address' => [ + '@type' => 'PostalAddress', + 'streetAddress' => 123 // Invalid: should be string + ] + ], + 'reviewBody' => 'Great!' + ]); + + $errors = SchemaValidator::validate($thing); + $this->assertIsArray($errors); + } + + public function testEmptyStringValues() + { + $data = [ + 'name' => ' ', // Empty after trim + 'url' => 'https://example.com' + ]; + + $errors = SchemaValidator::validateType('LocalBusiness', $data); + $this->assertIsArray($errors); + $this->assertContains('name is required', $errors); + } + + public function testInvalidSchemaStructure() + { + $data = ['name' => 'Test']; // No @type, no @graph + + $errors = SchemaValidator::validate($data); + $this->assertIsArray($errors); + $this->assertContains('Schema array must have @type property or @graph structure', $errors); + } + + public function testGraphWithInvalidStaff() + { + $data = ['@graph' => 'not an array']; + + $errors = SchemaValidator::validate($data); + $this->assertIsArray($errors); + $this->assertContains('@graph must be an array', $errors); + + $data = [ + '@graph' => [ + ['name' => 'Test'] // Missing @type + ] + ]; + + $errors = SchemaValidator::validate($data); + $this->assertIsArray($errors); + $this->assertStringContainsString('missing @type property', implode(' ', $errors)); + + $data = [ + '@graph' => [ + 'not an assoc array' + ] + ]; + + $errors = SchemaValidator::validate($data); + $this->assertIsArray($errors); + $this->assertStringContainsString('must be an array', implode(' ', $errors)); + + + $data = [ + 'name' => 'Test', + 'url' => 'https://example.com', + 'image' => 123 // Should be string|array + ]; + + $errors = SchemaValidator::validateType('LocalBusiness', $data); + $this->assertIsArray($errors); + $this->assertStringContainsString('must be one of: url|@ImageObject', implode(' ', $errors)); + } + + public function testSchemaValidatorWithIdReferences() + { + // Create Thing with @id + $restaurant = new LocalBusiness([ + '@id' => 'https://example.com/#restaurant', + 'name' => 'Marrakech Palace', + 'address' => [ + '@type' => 'PostalAddress', + 'streetAddress' => '456 Rue de la Koutoubia', + 'addressLocality' => 'Marrakech', + 'addressRegion' => 'Marrakech-Safi', + 'postalCode' => '40000', + 'addressCountry' => 'MA' + ], + 'telephone' => '+212524445566', + 'url' => 'https://example.com/restaurant' + ]); + + // Create Organization with reference to restaurant + $organization = new Organization([ + '@id' => 'https://example.com/#organization', + 'name' => 'Marrakech Hospitality Group', + 'owns' => ['@id' => 'https://example.com/#restaurant'] + ]); + + // Create Review that references the restaurant + $review = new Thing(type: 'Review', props: [ + 'author' => new Thing(type: 'Person', props: ['name' => 'Ahmed Benali']), + 'reviewRating' => new Intangible(type: 'Rating', props: ['ratingValue' => 5]), + 'itemReviewed' => ['@id' => 'https://example.com/#restaurant'], + 'reviewBody' => 'Amazing traditional food!', + 'datePublished' => '2024-01-15' + ]); + + // Create Schema with all three things + $schema = new Schema($restaurant, $organization, $review); + + // Validate the schema + $errors = SchemaValidator::validate($schema); + $this->assertNull($errors, 'Schema with @id references should pass validation'); + + // Also test with array format + $schemaArray = $schema->jsonSerialize(); + $errors = SchemaValidator::validate($schemaArray); + $this->assertNull($errors, 'Schema array with @graph should pass validation'); + + // Verify the structure contains @id references + $this->assertArrayHasKey('@graph', $schemaArray); + $this->assertCount(3, $schemaArray['@graph']); + } + + public function testSchemaValidatorWithGraphStructure() + { + // Simulate schema with @graph structure + $graphData = [ + '@graph' => [ + [ + '@type' => 'LocalBusiness', + 'name' => 'Restaurant', + 'address' => [ + 'streetAddress' => '789 Boulevard Pasteur', + 'addressLocality' => 'Tangier', + 'addressRegion' => 'Tanger-Tétouan-Al Hoceïma', + 'postalCode' => '90000', + 'addressCountry' => 'MA' + ], + 'url' => 'https://example.com', + 'telephone' => '+212511111111' + ], + [ + '@type' => 'Organization', + 'name' => 'Restaurant Group', + 'url' => 'https://example.com/group', + 'sameAs' => ['https://facebook.com/example'] + ], + [ + '@type' => 'WebPage', + 'name' => 'Home Page', + 'url' => 'https://example.com', + 'description' => 'Welcome to our restaurant' + ] + ] + ]; + + // Note: The validator doesn't handle @graph structure directly + // We would validate each entity separately + $errors = []; + + // Validate each item in @graph + foreach ($graphData['@graph'] as $entity) + { + $type = $entity['@type']; + $entityErrors = SchemaValidator::validateType($type, $entity); + if ($entityErrors !== null) { + foreach ($entityErrors as $error) { + $errors[] = "{$type}: {$error}"; + } + } + } + + $this->assertEmpty($errors, 'All entities in @graph should pass validation'); + $this->assertNull(SchemaValidator::validateType('LocalBusiness', $graphData['@graph'][0])); + $this->assertNull(SchemaValidator::validateType('Organization', $graphData['@graph'][1])); + $this->assertNull(SchemaValidator::validateType('WebPage', $graphData['@graph'][2])); + } + public function testLocalBusinessValidatorWithMissingRequiredFields() { // Test with missing required fields @@ -40,7 +360,7 @@ public function testLocalBusinessValidatorWithMissingRequiredFields() // Missing required 'name' and 'address' ]; - $errors = SchemaValidator::validate('LocalBusiness', $data); + $errors = SchemaValidator::validateType('LocalBusiness', $data); $this->assertIsArray($errors); $this->assertContains('name is required', $errors); $this->assertContains('address is required', $errors); @@ -59,7 +379,7 @@ public function testLocalBusinessValidatorWithInvalidTypes() 'acceptsReservations' => 'yes' // Should be bool ]; - $errors = SchemaValidator::validate('LocalBusiness', $data); + $errors = SchemaValidator::validateType('LocalBusiness', $data); $this->assertIsArray($errors); } @@ -79,7 +399,7 @@ public function testLocalBusinessValidatorWithValidNestedAddress() 'telephone' => '+212524111111' ]; - $errors = SchemaValidator::validate('LocalBusiness', $data); + $errors = SchemaValidator::validateType('LocalBusiness', $data); $this->assertNull($errors, 'Should pass with valid nested address array'); } } From 5f0f1279050d606e6ad9aef95c09c4cdacd6544f Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 11 Jan 2026 00:32:31 +0100 Subject: [PATCH 18/45] readded schema gen tests --- tests/SchemaTest.php | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/SchemaTest.php diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php new file mode 100644 index 0000000..ef4ec3d --- /dev/null +++ b/tests/SchemaTest.php @@ -0,0 +1,58 @@ + 'https://example.com', + 'logo' => 'https://example.com/logo.png', + 'name' => 'Example Org', + 'contactPoint' => new Thing(type: 'ContactPoint', props: [ + 'telephone' => '+1-000-555-1212', + 'contactType' => 'customer service' + ]) + ]) + ); + + $this->assertEquals('{"url":"https:\/\/example.com","logo":"https:\/\/example.com\/logo.png","name":"Example Org","contactPoint":{"telephone":"+1-000-555-1212","contactType":"customer service","@type":"ContactPoint","@context":"https:\/\/schema.org"},"@type":"Organization","@context":"https:\/\/schema.org"}', json_encode($schema)); + + $product = new Thing(type: 'Product'); + $product->name = "Foo Bar"; + $product->sku = "sk12"; + $product->image = "/image.jpeg"; + $product->description = "testing"; + $product->offers = new Thing(type: 'Offer', props: [ + 'availability' => 'https://schema.org/InStock', + 'priceCurrency' => 'USD', + "price" => "119.99", + 'url' => 'https://gool.com', + ]); + + $webpage = new WebPage([ + '@id' => "https://example.com/product/#webpage", + 'url' => "https://example.com/product", + 'name' => 'Foo Bar', + ]); + + + $schema = new Schema( + $product, + $webpage + ); + + + $this->assertEquals('', (string) $schema); + + } +} From 8242b6f06165d4b80c93083b081cb0e7c53d6f7b Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Mon, 12 Jan 2026 00:16:34 +0100 Subject: [PATCH 19/45] clear gh fs view --- .github/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/.gitkeep diff --git a/.github/.gitkeep b/.github/.gitkeep new file mode 100644 index 0000000..e69de29 From 6f1935cd410414713013c77a2375625c3d64e5b6 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Mon, 12 Jan 2026 00:19:20 +0100 Subject: [PATCH 20/45] adds some methods and enhancments --- src/MetaTags.php | 306 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 247 insertions(+), 59 deletions(-) diff --git a/src/MetaTags.php b/src/MetaTags.php index 4ba5844..e890fd8 100644 --- a/src/MetaTags.php +++ b/src/MetaTags.php @@ -1,12 +1,12 @@ $v) + foreach ($meta as $k => $v) { - if (method_exists(static::class, $k)) { + if (is_array($v)) { + + foreach ($v as $kk => $vv) + { + if (method_exists($this, $k)) { + $this->$k($kk, $vv); + continue; + } + + $this->push($k, $vv); + } + + continue; + } + + if (method_exists($this, $k)) { $this->$k($v); continue; } - $this->meta($k, $v); + + $this->push('meta', ['name' => $k, 'content' => $v]); + } + + foreach ($og as $k => $v) + { + $this->og($k, $v); + } + + foreach ($twitter as $k => $v) + { + $this->twitter($k, $v); } } @@ -60,20 +79,20 @@ public function __construct(array $tags = []) * Set page and meta title * * @param string $title - * @return MetaTags + * @return self */ - public function title(string $title): MetaTags + public function title(string $title): self { - $this->title = Utils::escape($title); + $this->title = $title; return $this->meta('title', $title)->og('title', $title)->twitter('title', $title); } /** * Set page description. * @param string $desc - * @return MetaTags + * @return self */ - public function description(string $desc): MetaTags + public function description(string $desc): self { return $this->meta('description', $desc)->og('description', $desc)->twitter('description', $desc); } @@ -82,9 +101,9 @@ public function description(string $desc): MetaTags * Set a mobile link (Http header "Vary: User-Agent" is required) * * @param string $url - * @return MetaTags + * @return self */ - public function mobile(string $url): MetaTags + public function mobile(string $url): self { return $this->push('link', [ 'href' => $url, @@ -94,24 +113,64 @@ public function mobile(string $url): MetaTags } /** - * Set robots meta tags. + * Set robots meta tags * - * @param string $options For example: follow, index, max-snippet:-1, max-video-preview:-1, max-image-preview:large - * @param string $botName bot name or robots for all. - * @return MetaTags + * @param string|array $options index,follow OR ['index', 'follow', 'max-snippet' => -1] + * @param string $botName robots|googlebot|bingbot|etc + * @return self */ - public function robots(string $options, string $botName = 'robots'): MetaTags + public function robots(string|array $options, string $botName = 'robots'): self { + if (is_array($options)) { + + $parts = []; + foreach ($options as $k => $v) + { + $parts[] = is_int($k) ? $v : "{$k}:{$v}"; + } + $options = implode(', ', $parts); + } + return $this->meta($botName, $options); } + /** + * Set RSS or Atom feed link + * + * @param string $url feed URL + * @param string $type application/rss+xml|application/atom+xml + * @param string|null $title feed title + * @return self + */ + public function feed(string $url, string $type = 'application/rss+xml', ?string $title = null): self + { + return $this->push('link', [ + 'rel' => 'alternate', + 'title' => $title, + 'type' => $type, + 'href' => $url, + ]); + } + + /** + * Set search engine verification meta tag + * + * @param string $engine google|bing|yandex|pinterest|etc + * @param string $code verification code + * @return self + */ + public function verification(string $engine, string $code): self + { + return $this->meta("{$engine}-site-verification", $code); + } + /** * Set AMP link * * @param string $url - * @return MetaTags + * @return self */ - public function amp(string $url): MetaTags + public function amp(string $url): self { return $this->push('link', [ 'rel' => 'amphtml', @@ -123,9 +182,9 @@ public function amp(string $url): MetaTags * Set canonical url * * @param string $url - * @return MetaTags + * @return self */ - public function canonical(string $url): MetaTags + public function canonical(string $url): self { return $this->push('link', [ 'rel' => 'canonical', @@ -138,9 +197,9 @@ public function canonical(string $url): MetaTags * Set social media url. * * @param string $url - * @return MetaTags + * @return self */ - public function url(string $url): MetaTags + public function url(string $url): self { return $this->og('url', $url)->twitter('url', $url); } @@ -150,9 +209,9 @@ public function url(string $url): MetaTags * * @param string $lang for eg: en * @param string $url alternate language page url. - * @return MetaTags + * @return self */ - public function hreflang(string $lang, string $url): MetaTags + public function hreflang(string $lang, string $url): self { return $this->push('link', [ 'rel' => 'alternate', @@ -161,14 +220,35 @@ public function hreflang(string $lang, string $url): MetaTags ]); } + /** + * Set multiple alternate language URLs at once + * + * @param array $langUrls Associative array of lang => url pairs (e.g., ['en' => 'url', 'fr' => 'url']) + * @param string|null $default Optional x-default URL for language fallback + * @return self + */ + public function hreflangs(array $langUrls, ?string $default = null): self + { + if ($default !== null) { + $langUrls['x-default'] = $default; + } + + foreach ($langUrls as $lang => $url) + { + $this->hreflang($lang, $url); + } + + return $this; + } + /** * Set a meta tag * * @param string $name * @param string $value - * @return MetaTags + * @return self */ - public function meta(string $name, string $value): MetaTags + public function meta(string $name, string $value): self { return $this->push('meta', [ 'name' => $name, @@ -181,9 +261,9 @@ public function meta(string $name, string $value): MetaTags * * @param string $name * @param array $attrs - * @return MetaTags + * @return self */ - public function push(string $name, array $attrs): MetaTags + public function push(string $name, array $attrs): self { foreach ($attrs as $k => $v) { @@ -199,11 +279,11 @@ public function push(string $name, array $attrs): MetaTags * * @param string $name * @param string $value - * @return MetaTags + * @return self */ - public function og(string $name, string $value): MetaTags + public function og(string $name, string $value): self { - $this->openGraphTags[] = ['meta', ['property' => "og:{$name}", 'content' => $value]]; + $this->tags[] = ['meta', ['property' => "og:{$name}", 'content' => $value]]; return $this; } @@ -213,21 +293,21 @@ public function og(string $name, string $value): MetaTags * * @param string $name * @param string $value - * @return MetaTags + * @return self */ - public function twitter(string $name, string $value): MetaTags + public function twitter(string $name, string $value): self { - $this->twitterTags[] = ['meta', ['property' => "twitter:{$name}", 'content' => $value]]; + $this->tags[] = ['meta', ['property' => "twitter:{$name}", 'content' => $value]]; return $this; } /** * Set short link tag - * + * * @param string $url - * @return MetaTags + * @return self */ - public function shortlink(string $url): MetaTags + public function shortlink(string $url): self { return $this->push('link', [ 'rel' => 'shortlink', @@ -240,13 +320,78 @@ public function shortlink(string $url): MetaTags * * @param string $url * @param string $card Twitter card - * @return MetaTags + * @return self */ - public function image(string $url, string $card = 'summary_large_image'): MetaTags + public function image(string $url, string $card = 'summary_large_image'): self { return $this->og('image', $url)->twitter('card', $card)->twitter('image', $url); } + /** + * Set article metadata + * + * @param string $published Article published time + * @param string|null $modified Article modified time + * @param string|null $author Article author + * @return self + */ + public function articleMeta(string $published, ?string $modified = null, ?string $author = null): self + { + $this->og('article:published_time', $published); + + if ($modified) { + $this->og('article:modified_time', $modified); + } + + if ($author) { + $this->og('article:author', $author); + } + + return $this; + } + + /** + * Set pagination links + * + * @param string|null $prev previous page URL + * @param string|null $next next page URL + * @param string|null $first first page URL (optional) + * @param string|null $last last page URL (optional) + * @return self + */ + public function pagination(?string $prev = null, ?string $next = null, ?string $first = null, ?string $last = null): self + { + if ($prev) { + $this->push('link', ['rel' => 'prev', 'href' => $prev]); + } + + if ($next) { + $this->push('link', ['rel' => 'next', 'href' => $next]); + } + + if ($first) { + $this->push('link', ['rel' => 'first', 'href' => $first]); + } + + if ($last) { + $this->push('link', ['rel' => 'last', 'href' => $last]); + } + + return $this; + } + + /** + * Add Schema objects to be rendered with metatags + * + * @param SchemaInterface $schema Any Schema object + * @return self + */ + public function schema(SchemaInterface $schema): self + { + $this->schema = $schema; + return $this; + } + /** * Build meta tags * @@ -255,20 +400,62 @@ public function image(string $url, string $card = 'summary_large_image'): MetaTa */ public function build(array $tags): string { - $out = ''; + // Sort tags for nice readability + usort($tags, function($a, $b) + { + $getType = function($tag) + { + if (isset($tag[1]['property'])) { + + if (str_starts_with($tag[1]['property'], 'og:')) { + return 3; + } + + if (str_starts_with($tag[1]['property'], 'twitter:')) { + return 4; + } + } + + if ($tag[0] === 'meta') { + return 1; + } + if ($tag[0] === 'link') { + return 2; + } + + return 5; + }; + + return $getType($a) <=> $getType($b); + }); + + $out = ''; foreach ($tags as $tag) { $out .= "\n<{$tag[0]} "; foreach ($tag[1] as $a => $v) { + // empty values will be skipped. + if (!$v) { + continue; + } + + // attrs values are escaped to avoid XSS attacks, but attrs names MUST be trusted! + // if you trust your users to set arbitary meta attr names that a STUPID idea, but + // anyway I did a small replace to avid common XSS chars that may hack you! + $a = str_replace(['"', "'", '<', '>', ' ', "\t", "\n", "\r"], '', $a); $out .= $a .'="'. Utils::escape($v) .'" '; } $out .= "/>"; } + if ($this->schema !== null) { + $out .= (string) $this->schema; + } + return $out; } @@ -282,9 +469,10 @@ public function __toString(): string { $title = ''; if ($this->title !== null) { - $title = "{$this->title}"; + $title = Utils::escape($this->title); + $title = "{$title}"; } - return $title . $this->build($this->tags) . $this->build($this->twitterTags) . $this->build($this->openGraphTags) ; + return $title . $this->build($this->tags); } } From 5acef4ff5e9b3b6d7f8b67756af59b37caeec54c Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Mon, 12 Jan 2026 00:19:37 +0100 Subject: [PATCH 21/45] test cases for meta tags. --- tests/MetaTagsTest.php | 124 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/tests/MetaTagsTest.php b/tests/MetaTagsTest.php index 0937f58..6613093 100644 --- a/tests/MetaTagsTest.php +++ b/tests/MetaTagsTest.php @@ -3,6 +3,8 @@ use PHPUnit\Framework\TestCase; use Melbahja\Seo\MetaTags; +use Melbahja\Seo\Schema; +use Melbahja\Seo\Schema\Thing; class MetaTagsTest extends TestCase { @@ -18,7 +20,7 @@ public function testMetaTags() 'author' => 'Mohamed Elbahja' ]); - $this->assertEquals('My new article', + $this->assertEquals('My new article', str_replace("\n", '', (string) $metatags) ); @@ -37,9 +39,127 @@ public function testMetaTags() $this->assertNotEmpty((string) $metatags); - $this->assertEquals('PHP SEO', + $this->assertEquals('PHP SEO', str_replace("\n", '', (string)$metatags) ); } + + public function testConstructorProps() + { + $metatags = new MetaTags( + meta: [ + 'title' => 'Test Page', + 'description' => 'Test description', + 'keywords' => 'php, test', + 'author' => 'Mohamed Elbahja', + 'theme-color' => '#ffffff', + 'robots' => 'index, follow', + 'canonical' => 'https://example.com', + + // two args methods like verification: + // methdName(arg, value), in this case verification(google, abc123) + // verification, robots, feed, hreflang, image... + 'verification' => [ + 'google' => 'abc123', + ], + + 'link' => [ + ['rel' => 'alternate', 'href' => 'https://example.com/fr', 'hreflang' => 'fr'], + ['rel' => 'alternate', 'href' => 'https://example.com/es', 'hreflang' => 'es'], + ], + + ], + og: [ + 'type' => 'article', + 'locale' => 'en_US', + 'site_name' => 'My Site', + ], + twitter: [ + 'card' => 'summary_large_image', + 'author' => '@dev0x0', + ], + ); + + $output = (string) $metatags; + + // var_export($output); + + $this->assertStringContainsString('Test Page', $output); + $this->assertStringContainsString('name="description" content="Test description"', $output); + $this->assertStringContainsString('name="keywords" content="php, test"', $output); + $this->assertStringContainsString('name="author" content="Mohamed Elbahja"', $output); + $this->assertStringContainsString('name="theme-color" content="#ffffff"', $output); + $this->assertStringContainsString('name="robots" content="index, follow"', $output); + $this->assertStringContainsString('name="google-site-verification" content="abc123"', $output); + $this->assertStringContainsString('rel="canonical" href="https://example.com"', $output); + $this->assertStringContainsString('hreflang="fr"', $output); + $this->assertStringContainsString('hreflang="es"', $output); + $this->assertStringContainsString('property="og:type" content="article"', $output); + $this->assertStringContainsString('property="og:locale" content="en_US"', $output); + $this->assertStringContainsString('property="og:site_name" content="My Site"', $output); + $this->assertStringContainsString('property="twitter:card" content="summary_large_image"', $output); + $this->assertStringContainsString('property="twitter:author" content="@dev0x0"', $output); + } + + + public function testMetaTagsWithSchema() + { + $metatags = new MetaTags([ + 'title' => 'Test Page', + 'description' => 'Test description', + 'keywords' => 'php, test', + 'author' => 'Mohamed Elbahja', + 'theme-color' => '#ffffff', + 'robots' => 'index, follow', + 'canonical' => 'https://example.com', + ]); + + $metatags->schema(new Schema( + new Thing(type: 'Organization', props: [ + 'url' => 'https://example.com', + 'logo' => 'https://example.com/logo.png', + 'name' => 'Example Org', + 'contactPoint' => new Thing(type: 'ContactPoint', props: [ + 'telephone' => '+1-000-555-1212', + 'contactType' => 'customer service' + ]) + ]) + )); + + $output = (string) $metatags; + + $output2 = (string) new MetaTags([ + 'title' => 'Test Page', + 'description' => 'Test description', + 'keywords' => 'php, test', + 'author' => 'Mohamed Elbahja', + 'theme-color' => '#ffffff', + 'robots' => 'index, follow', + 'canonical' => 'https://example.com', + 'schema' => new Schema( + new Thing(type: 'Organization', props: [ + 'url' => 'https://example.com', + 'logo' => 'https://example.com/logo.png', + 'name' => 'Example Org', + 'contactPoint' => new Thing(type: 'ContactPoint', props: [ + 'telephone' => '+1-000-555-1212', + 'contactType' => 'customer service' + ]) + ]) + ), + ]); + + $this->assertEquals($output, $output2); + $this->assertStringContainsString('Test Page', $output); + $this->assertStringContainsString('name="description" content="Test description"', $output); + $this->assertStringContainsString('name="keywords" content="php, test"', $output); + $this->assertStringContainsString('name="author" content="Mohamed Elbahja"', $output); + $this->assertStringContainsString('name="theme-color" content="#ffffff"', $output); + $this->assertStringContainsString('name="robots" content="index, follow"', $output); + $this->assertStringContainsString('rel="canonical" href="https://example.com"', $output); + $this->assertStringContainsString('"@type":"ContactPoint"', $output); + $this->assertStringContainsString(''; diff --git a/src/Sitemap.php b/src/Sitemap.php index fdac97e..f191360 100644 --- a/src/Sitemap.php +++ b/src/Sitemap.php @@ -1,8 +1,6 @@ builders[$alias] = $builder; @@ -118,7 +116,7 @@ public function getSitemapBaseUrl(): string } /** - * Set sitemaps to a file name. + * Write sitemaps to a file/stream or return as index string in case of memory mode. * * @param string|null $uriPath URI path to render the sitemap into, or null will return the xml * @return bool|string boolean when uri oath passed or a generated xml as string @@ -144,6 +142,11 @@ public function render(?string $uriPath = null): bool|string return $index->render($uriPath); } + /** + * return the sitemap index as string in case of memory mode, or write to targets. + * + * @return string + */ public function __toString(): string { return $this->render(); @@ -215,7 +218,7 @@ public function __call(string $alias, array $args): self throw new SitemapException("The sitemap {$options['name']} already registred!"); } - if (is_array($args[1]) === false && is_callable($args[1]) === false && ($args[1] instanceof Traversable) === false) { + if (is_array($args[1]) === false && is_callable($args[1]) === false && ($args[1] instanceof \Traversable) === false) { throw new SitemapException("{$alias}() Argument[1] must be array, callable, or Traversable"); } diff --git a/src/Sitemap/IndexBuilder.php b/src/Sitemap/IndexBuilder.php index f3f91af..8e003de 100644 --- a/src/Sitemap/IndexBuilder.php +++ b/src/Sitemap/IndexBuilder.php @@ -103,11 +103,11 @@ public function __construct( case OutputMode::STREAM: - if (function_exists('\xmlwriter_open_memory')) { + if (method_exists(XMLWriter::class, 'toStream')) { $this->writer = XMLWriter::toStream($stream); - } else { // php <= 8.4 workaround + } else { // php < 8.4 workaround $this->tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . md5(uniqid()) . '.xml'; $this->writer = new XMLWriter(); @@ -123,7 +123,6 @@ public function __construct( } $this->options = array_merge($this->defaultOptions, $options); - if ($this->options['indent'] !== null) { $this->writer->setIndent(true); $this->writer->setIndentString($this->options['indent']); @@ -153,11 +152,7 @@ public function url(string $url): self throw new SitemapException("The maximum sitemaps has been exhausted"); } - if (str_contains($url, '://') === false) { - $url = $this->baseUrl . ($url[0] !== '/' ? "/{$url}" : $url); - } - - $this->sitemap['loc'] = Utils::encodeSitemapUrl($url); + $this->sitemap['loc'] = Utils::encodeSitemapUrl(Utils::resolveRelativeUrl($this->baseUrl, $url)); return $this; } @@ -166,7 +161,7 @@ public function url(string $url): self */ public function lastMod(string|int $date): self { - $this->sitemap['lastmod'] = $this->parseDate($date); + $this->sitemap['lastmod'] = Utils::formatDate($date); return $this; } @@ -180,12 +175,10 @@ public function commit(): self } $this->writer->startElement('sitemap'); - foreach ($this->sitemap as $name => $value) { $this->writer->writeElement($name, (string) $value); } - $this->writer->endElement(); $this->maxUrls--; @@ -195,7 +188,7 @@ public function commit(): self } /** - * Save generated sitemap index + * Save/Render generated sitemap xml * * @param string|null $uriPath can only be passed on OutputMode::MEMORY * @return bool|string bool in case of mode is not memory, and string if writing to memory. @@ -220,8 +213,8 @@ public function render(?string $uriPath = null): bool|string return rename($this->tempPath, $this->filePath); } - // php <= 8.4 workaround - if ($this->mode === OutputMode::STREAM && function_exists('\xmlwriter_open_memory') === false) { + // php < 8.4 workaround + if ($this->mode === OutputMode::STREAM && method_exists(XMLWriter::class, 'toStream') === false) { $tempFd = fopen($this->tempPath, 'r'); $stcopy = stream_copy_to_stream($tempFd, $this->stream); @@ -234,7 +227,7 @@ public function render(?string $uriPath = null): bool|string } /** - * Get XML as string in case of memory mode + * Get XML as string in case of memory mode, other modes will write to target. */ public function __toString(): string { @@ -242,19 +235,8 @@ public function __toString(): string } /** - * TODO: move to utils this and Links + * Cleanup */ - protected function parseDate(string|int $date): string - { - $timestamp = is_int($date) ? $date : strtotime($date); - - if ($timestamp === false) { - throw new SitemapException("Invalid date format: {$date}"); - } - - return date('c', $timestamp); - } - public function __destruct() { if (isset($this->tempPath) && file_exists($this->tempPath)) { diff --git a/src/Sitemap/LinksBuilder.php b/src/Sitemap/LinksBuilder.php index 3085fce..47c924a 100644 --- a/src/Sitemap/LinksBuilder.php +++ b/src/Sitemap/LinksBuilder.php @@ -187,11 +187,7 @@ public function url(string $url): self throw new SitemapException("The maximum urls has been exhausted"); } - if (str_contains($url, '://') === false) { - $url = $this->baseUrl . ($url[0] !== '/' ? "/{$url}" : $url); - } - - $this->url['loc'] = Utils::encodeSitemapUrl($url); + $this->url['loc'] = Utils::encodeSitemapUrl(Utils::resolveRelativeUrl($this->baseUrl, $url)); return $this; } @@ -203,11 +199,7 @@ public function url(string $url): self */ public function alternate(string $url, string $lang): self { - if (str_contains($url, '://') === false) { - $url = $this->baseUrl . ($url[0] !== '/' ? "/{$url}" : $url); - } - - $this->url['alternate'][] = [Utils::encodeSitemapUrl($url), $lang]; + $this->url['alternate'][] = [$url, $lang]; // encoded and escaped in commit return $this; } @@ -233,6 +225,7 @@ public function addItem(SitemapUrl $url): self $this->priority($url->priority); } + // I assume you're using a news builder! if ($url->news !== null) { $this->news($url->news); } @@ -292,9 +285,29 @@ public function commit(): self foreach ($vid as $key => $val) { + // multiple video nodes + // like multi + if (is_array($val) && array_is_list($val)) { + + foreach ($val as $item) + { + $item = is_array($item) ? $item : ['value' => $item]; + + $this->writer->startElementNs('video', $key, null); + foreach (($item['attrs'] ?? []) as $attr => $aVal) + { + $this->writer->writeAttribute($attr, $aVal); + } + $this->writeText($item['value'], 'video:' . $key); + $this->writer->endElement(); + } + continue; + } + $val = is_array($val) ? $val : ['value' => $val]; $this->writer->startElementNs('video', $key, null); + foreach (($val['attrs'] ?? []) as $attr => $aVal) { $this->writer->writeAttribute($attr, $aVal); @@ -338,7 +351,7 @@ public function commit(): self { $this->writer->startElementNs('xhtml', 'link', null); $this->writer->writeAttribute('rel', 'alternate'); - $this->writer->writeAttribute('href', Utils::encodeSitemapUrl($alt[0])); + $this->writer->writeAttribute('href', Utils::encodeSitemapUrl(Utils::resolveRelativeUrl($this->baseUrl, $alt[0]))); $this->writer->writeAttribute('hreflang', $alt[1]); $this->writer->endElement(); } @@ -363,7 +376,7 @@ public function commit(): self */ public function lastMod(string|int $date): self { - $this->url['lastmod'] = $this->parseDate($date); + $this->url['lastmod'] = Utils::formatDate($date); return $this; } @@ -377,7 +390,7 @@ public function image(string $imageUrl, array $options = []): self } $this->url['image'][] = array_merge($options, [ - 'loc' => $this->getByRelativeUrl($imageUrl), + 'loc' => Utils::resolveRelativeUrl($this->baseUrl, $imageUrl), ]); return $this; @@ -415,7 +428,7 @@ public function video(string $title, array $options = []): self throw new SitemapException("Raw video url content_loc or player_loc embed is required"); } - $options['thumbnail_loc'] = $this->getByRelativeUrl($options['thumbnail_loc']); + $options['thumbnail_loc'] = Utils::resolveRelativeUrl($this->baseUrl, $options['thumbnail_loc']); $this->url['video'][] = $options; return $this; @@ -490,40 +503,13 @@ public function render(?string $uriPath = null): bool|string /** - * Get XML as string in case of memory mode + * Get XML as string in case of memory mode, or write to target. */ public function __toString(): string { return $this->render(); } - /** - * Fix relative urls - * TODO: move to utils. - */ - protected function getByRelativeUrl(string $url): string - { - if (str_contains($url, '://') === false) { - return $this->baseUrl . ($url[0] !== '/' ? "/{$url}" : $url); - } - - return $url; - } - - /** - * Convert date to ISO8601 format - */ - protected function parseDate(string|int $date): string - { - $timestamp = is_int($date) ? $date : strtotime($date); - - if ($timestamp === false) { - throw new SitemapException("Invalid date format: {$date}"); - } - - return date('c', $timestamp); - } - /** * Write element with optional CDATA wrapping * @@ -531,19 +517,17 @@ protected function parseDate(string|int $date): string * @param string $key element name * @param mixed $value element value */ - protected function writeElement(string $namespace, string $key, mixed $value): void + protected function writeElement(string $namespace, string $key, mixed $value): bool { - $fullKey = "{$namespace}:{$key}"; - if ($this->shouldUseCData($fullKey, $value)) { + if ($this->shouldUseCData("{$namespace}:{$key}", $value)) { $this->writer->startElementNs($namespace, $key, null); $this->writer->writeCData((string) $value); - $this->writer->endElement(); - return; + return $this->writer->endElement(); } - $this->writer->writeElementNs($namespace, $key, null, (string) $value); + return $this->writer->writeElementNs($namespace, $key, null, (string) $value); } /** @@ -567,18 +551,19 @@ protected function writeText(mixed $value, string $field = ''): bool protected function shouldUseCData(string $field, mixed $value = null): bool { if (in_array($field, $this->options['cdata'], true)) { - return true; + } - } else if ($value === null) { - + if ($value === null || ($value = (string) $value) === '') { return false; } - $value = (string) $value; return str_contains($value, '<') || str_contains($value, '>') || str_contains($value, '&'); } + /** + * Cleanup + */ public function __destruct() { if (isset($this->tempPath) && file_exists($this->tempPath)) { diff --git a/src/Sitemap/NewsBuilder.php b/src/Sitemap/NewsBuilder.php index ba01c76..8cf95d6 100644 --- a/src/Sitemap/NewsBuilder.php +++ b/src/Sitemap/NewsBuilder.php @@ -42,12 +42,7 @@ public function preSetup(array $options): array */ public function setPublication(string $name, string $lang): SitemapBuilderInterface { - $this->publication = - [ - 'name' => $name, - 'lang' => $lang - ]; - + $this->publication = ['name' => $name, 'lang' => $lang]; return $this; } diff --git a/src/Utils/HttpClient.php b/src/Utils/HttpClient.php index 2ad9f78..fa5d9e4 100644 --- a/src/Utils/HttpClient.php +++ b/src/Utils/HttpClient.php @@ -1,5 +1,5 @@ headers, $headers); $headersList = []; + $hasUAgent = false; + foreach ($headers as $key => $value) { - // Just skip it! - if ($isJson && strtolower($key) === 'content-type') { - continue; + $key = strtolower($key); + if ($isJson && $key === 'content-type') { + continue; // if we have json payload we must have json ctype! + } else if ($key === 'user-agent') { + $hasUAgent = true; } $headersList[] = "$key: $value"; } @@ -64,6 +68,11 @@ public function request(string $method, string $url, $body = null, array $header if ($isJson) { $headersList[] = "content-type: application/json"; } + + if ($hasUAgent === false) { + $headersList[] = "user-agent: phpseo/v3 (+http://git.io/phpseo)"; + } + curl_setopt($ch, CURLOPT_HTTPHEADER, $headersList); $response = curl_exec($ch); @@ -95,4 +104,4 @@ private function buildUrl(string $url): string return $this->baseUrl . '/' . ltrim($url, '/'); } -} \ No newline at end of file +} diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index 8e893bc..bf58ae9 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -1,8 +1,8 @@ callable)(); } }; } -} + /** + * Resolve a relative URL against a base URL. + * + * @param string $baseUrl + * @param string $url relative or absolute URL. + * @return string absolute URL. + */ + public static function resolveRelativeUrl(string $baseUrl, string $url): string + { + if (str_contains($url, '://') === false) { + return rtrim($baseUrl, '/') . ($url[0] !== '/' ? "/{$url}" : $url); + } + + return $url; + } + + /** + * Normalize a date value to ISO 8601 format. + * + * @param string|int $date + * @return string ISO 8601 date. + * @throws SeoException if the format is invalid. + */ + public static function formatDate(string|int $date): string + { + if (($timestamp = is_int($date) ? $date : strtotime($date)) !== false) { + return date('c', $timestamp); + } + + throw new SeoException("Invalid date format: {$date}"); + } +} diff --git a/src/Validation/RobotsValidator.php b/src/Validation/RobotsValidator.php index 2ccbe61..fc2dd52 100644 --- a/src/Validation/RobotsValidator.php +++ b/src/Validation/RobotsValidator.php @@ -1,8 +1,6 @@ 'array|@Thing', 'item_type' => '@Thing', ], -]; \ No newline at end of file +]; diff --git a/src/Validation/SchemaRules/Event.php b/src/Validation/SchemaRules/Event.php index c2a73f5..f81f03c 100644 --- a/src/Validation/SchemaRules/Event.php +++ b/src/Validation/SchemaRules/Event.php @@ -24,4 +24,4 @@ 'organizer' => '@Organization|@Person', 'performer' => '@Person|@Organization', 'previousStartDate' => 'string', -]; \ No newline at end of file +]; diff --git a/src/Validation/SchemaRules/Person.php b/src/Validation/SchemaRules/Person.php index 265bfe6..b7f13a5 100644 --- a/src/Validation/SchemaRules/Person.php +++ b/src/Validation/SchemaRules/Person.php @@ -14,4 +14,3 @@ 'description' => 'string', 'jobTitle' => 'string', ]; - diff --git a/src/Validation/SchemaRules/Quiz.php b/src/Validation/SchemaRules/Quiz.php index 84bbc58..69e2c5d 100644 --- a/src/Validation/SchemaRules/Quiz.php +++ b/src/Validation/SchemaRules/Quiz.php @@ -11,4 +11,4 @@ 'type' => 'array|@Thing', 'item_type' => '@Thing', ], -]; \ No newline at end of file +]; diff --git a/src/Validation/SchemaRules/Recipe.php b/src/Validation/SchemaRules/Recipe.php index bd76293..ca896cb 100644 --- a/src/Validation/SchemaRules/Recipe.php +++ b/src/Validation/SchemaRules/Recipe.php @@ -26,4 +26,4 @@ 'recipeYield' => 'string|int', 'aggregateRating' => '@AggregateRating', 'video' => '@VideoObject', -]; \ No newline at end of file +]; diff --git a/src/Validation/SchemaValidator.php b/src/Validation/SchemaValidator.php index ef08801..70dab04 100644 --- a/src/Validation/SchemaValidator.php +++ b/src/Validation/SchemaValidator.php @@ -296,6 +296,7 @@ private static function checkType($value, string $type, array $rule): ?array switch ($type) { case 'string': + if (!is_string($value)) { $errors[] = "must be a string"; } @@ -303,12 +304,14 @@ private static function checkType($value, string $type, array $rule): ?array case 'int': case 'integer': + if (!is_int($value) && !is_numeric($value)) { $errors[] = "must be an integer"; } break; case 'float': + if (!is_float($value) && !is_int($value) && !is_numeric($value)) { $errors[] = "must be a float"; } @@ -316,30 +319,35 @@ private static function checkType($value, string $type, array $rule): ?array case 'bool': case 'boolean': + if (!is_bool($value) && $value != 'true' && $value != 'false') { $errors[] = "must be a boolean"; } break; case 'array': + if (!is_array($value)) { $errors[] = "must be an array"; } break; case 'iso_date': + if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}/', $value)) { $errors[] = "must be a valid ISO date (YYYY-MM-DD)"; } break; case 'url': + if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_URL)) { $errors[] = "must be a valid URL"; } break; case 'email': + if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_EMAIL)) { $errors[] = "must be a valid email"; } @@ -406,7 +414,6 @@ private static function loadRules(string $schemaType): array private static function isEmpty($value): bool { - if (is_array($value)) { return empty($value); } else if (is_string($value)) { From d30f4309935a4b84dbbda2cc2a575fcf14d08039 Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 18 Jan 2026 23:45:07 +0100 Subject: [PATCH 44/45] reorder type and context --- src/Schema/Thing.php | 6 +----- tests/SchemaTest.php | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Schema/Thing.php b/src/Schema/Thing.php index df1bfa3..1435220 100644 --- a/src/Schema/Thing.php +++ b/src/Schema/Thing.php @@ -89,11 +89,7 @@ public function __call(string $name, array $args): self */ public function jsonSerialize(): array { - return array_merge($this->props, - [ - '@type' => $this->type, - '@context' => $this->context, - ]); + return array_merge(['@type' => $this->type, '@context' => $this->context], $this->props); } /** diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 9a4886d..422f9b8 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -26,7 +26,7 @@ public function testSchemaResults() ]) ); - $this->assertEquals('{"url":"https:\/\/example.com","logo":"https:\/\/example.com\/logo.png","name":"Example Org","contactPoint":{"telephone":"+1-000-555-1212","contactType":"customer service","@type":"ContactPoint","@context":"https:\/\/schema.org"},"@type":"Organization","@context":"https:\/\/schema.org"}', json_encode($schema)); + $this->assertEquals('{"@type":"Organization","@context":"https:\/\/schema.org","url":"https:\/\/example.com","logo":"https:\/\/example.com\/logo.png","name":"Example Org","contactPoint":{"@type":"ContactPoint","@context":"https:\/\/schema.org","telephone":"+1-000-555-1212","contactType":"customer service"}}', json_encode($schema)); $product = new Product(); $product->name = "Foo Bar"; @@ -58,7 +58,7 @@ public function testSchemaResults() ); - $this->assertEquals('', (string) $schema); + $this->assertEquals('', (string) $schema); } } From 08c725869e48abad6c9e70aea06a7fa5c6265b9e Mon Sep 17 00:00:00 2001 From: Mohamed Elbahja Date: Sun, 18 Jan 2026 23:45:46 +0100 Subject: [PATCH 45/45] update mata info --- README.md | 630 ++++++++++++++++++++------------------------------ composer.json | 16 +- 2 files changed, 262 insertions(+), 384 deletions(-) diff --git a/README.md b/README.md index a3d4070..bee11d4 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ -# PHP SEO -[![Build Status](https://github.com/melbahja/seo/workflows/Test/badge.svg)](https://github.com/melbahja/seo/actions?query=workflow%3ATest) [![GitHub license](https://img.shields.io/github/license/melbahja/seo)](https://github.com/melbahja/seo/blob/master/LICENSE) ![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/melbahja/seo) ![Packagist Version](https://img.shields.io/packagist/v/melbahja/seo) [![Twitter](https://img.shields.io/twitter/url/https/github.com/melbahja/seo.svg?style=social)](https://twitter.com/intent/tweet?url=https%3A%2F%2Fgithub.com%2Fmelbahja%2Fseo) +# PHP SEO + +The SEO library for PHP is a simple and powerful PHP library to help developers 🍻 do better on-page SEO optimizations. + +[![Build Status](https://github.com/melbahja/seo/workflows/Test/badge.svg)](https://github.com/melbahja/seo/actions?query=workflow%3ATest) +[![GitHub license](https://img.shields.io/github/license/melbahja/seo)](https://github.com/melbahja/seo/blob/master/LICENSE) +![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/melbahja/seo) +![Packagist Version](https://img.shields.io/packagist/v/melbahja/seo) +[![Twitter](https://img.shields.io/twitter/url/https/github.com/melbahja/seo.svg?style=social)](https://twitter.com/intent/tweet?url=https%3A%2F%2Fgithub.com%2Fmelbahja%2Fseo) -Simple PHP library to help developers 🍻 do better on-page SEO optimization ### PHP SEO features: -- [[👷]](#-generate-schemaorg) **Generate schema.org ld+json** -- [[🛀]](#-meta-tags) **Generate meta tags with twitter and open graph support** -- [[🌐]](#-sitemaps) **Generate sitemaps xml and indexes (supports: 🖺 news, 🖼 images, 📽 videos)** -- [[📤]](#-send-sitemaps-to-search-engines) **Submit new sitemaps to search engines** -- [[📤]](#-indexing-api) **Indexing API** -- [[🙈]](https://github.com/melbahja/seo/blob/master/composer.json) **No dependencies** +- [[👷]](#-generate-schemaorg) **Generate Rich Results schema.org ld+json** +- [[🛀]](#-meta-tags) **Generate Meta Tags with Twitter and Open Graph Support** +- [[🌐]](#-sitemaps) **Generate XML Sitemaps (supports: 🖺 News Sitemaps, 🖼 Images Sitemaps, 📽 Video Sitemaps, Index Sitemaps)** +- [[📤]](#-indexing-api) **IndexNow and Google Indexing API** +- [✅] **Schema Rich Results Validator** +- [[🧩]](https://github.com/melbahja/seo/blob/master/composer.json) **Zero Dependencies** ## Installation: ```bash @@ -18,19 +24,20 @@ composer require melbahja/seo ``` ## Usage: -Check this simple examples. (of course the composer autoload.php file is required) +Check this simple examples. #### 👷 Generate schema.org ```php use Melbahja\Seo\Schema; use Melbahja\Seo\Schema\Thing; +use Melbahja\Seo\Schema\Organization; $schema = new Schema( - new Thing('Organization', [ + new Organization([ 'url' => 'https://example.com', 'logo' => 'https://example.com/logo.png', - 'contactPoint' => new Thing('ContactPoint', [ + 'contactPoint' => new Thing(type: 'ContactPoint', props: [ 'telephone' => '+1-000-555-1212', 'contactType' => 'customer service' ]) @@ -44,21 +51,16 @@ echo $schema; ```html ``` @@ -66,20 +68,21 @@ echo $schema; ```php use Melbahja\Seo\Schema; use Melbahja\Seo\Schema\Thing; +use Melbahja\Seo\Schema\CreativeWork\WebPage; -$product = new Thing('Product'); +$product = new Thing(type: 'Product'); $product->name = "Foo Bar"; $product->sku = "sk12"; $product->image = "/image.jpeg"; $product->description = "testing"; -$product->offers = new Thing('Offer', [ +$product->offers = new Thing(type: 'Offer', props: [ 'availability' => 'https://schema.org/InStock', 'priceCurrency' => 'USD', "price" => "119.99", 'url' => 'https://gool.com', ]); -$webpage = new Thing("WebPage", [ +$webpage = new WebPage([ '@id' => "https://example.com/product/#webpage", 'url' => "https://example.com/product", 'name' => 'Foo Bar', @@ -93,35 +96,36 @@ $schema = new Schema( echo json_encode($schema, JSON_PRETTY_PRINT); ``` + **Results:** ```json { - "@context": "https://schema.org", - "@graph": [ - { - "name": "Foo Bar", - "sku": "sk12", - "image": "/image.jpeg", - "description": "testing", - "offers": { - "availability": "https://schema.org/InStock", - "priceCurrency": "USD", - "price": "119.99", - "url": "https://gool.com", - "@type": "Offer", - "@context": "https://schema.org/" - }, - "@type": "Product", - "@context": "https://schema.org/" - }, - { - "@id": "https://example.com/product/#webpage", - "url": "https://example.com/product", - "name": "Foo Bar", - "@type": "WebPage", - "@context": "https://schema.org/" - } - ] + "@context": "https:\/\/schema.org", + "@graph": [ + { + "@type": "Product", + "@context": "https:\/\/schema.org", + "name": "Foo Bar", + "sku": "sk12", + "image": "\/image.jpeg", + "description": "testing", + "offers": { + "@type": "Offer", + "@context": "https:\/\/schema.org", + "availability": "https:\/\/schema.org\/InStock", + "priceCurrency": "USD", + "price": "119.99", + "url": "https:\/\/gool.com" + } + }, + { + "@type": "WebPage", + "@context": "https:\/\/schema.org", + "@id": "https:\/\/example.com\/product\/#webpage", + "url": "https:\/\/example.com\/product", + "name": "Foo Bar" + } + ] } ``` @@ -135,12 +139,21 @@ $metatags = new MetaTags(); $metatags ->title('PHP SEO') ->description('This is my description') - ->meta('author', 'Mohamed Elabhja') + ->meta('author', 'Mohamed Elbahja') ->image('https://avatars3.githubusercontent.com/u/8259014') ->mobile('https://m.example.com') ->canonical('https://example.com') ->shortlink('https://git.io/phpseo') - ->amp('https://apm.example.com'); + ->amp('https://apm.example.com') + ->robots(['index', 'follow', 'max-snippet' => -1]) + ->robots(botName: 'bingbot', options: ['index', 'nofollow']) + ->feed("https://example.com/feed.rss") + ->verification("google", "token_value") + ->verification("yandex", "token_value") + ->hreflang("de", "https://de.example.com") + ->og("type", "website") + ->twitter("creator", "Mohamed Elbahja"); + // ->schema($schema) echo $metatags; @@ -151,401 +164,266 @@ echo $metatags; PHP SEO - + + + + + + + + + + + - - - + ``` -#### 🗺 Sitemaps -```php -$yourmap = new Sitemap(string $url, array $options = []): SitemapIndexInterface -``` -| Option name | Description | Required ? | Default | -| ------------- | ------------- | --------- | -------- | -| save_path | Generated sitemaps storage path | YES | | -| sitemaps_url | Sitemap index custom url for generated sitemaps | NO | $url | -| index_name | Custom sitemap index name | NO | sitemap.xml | +# 🗺 Sitemaps + +Generate XML sitemaps with support for images, videos, news, and localized URLs. + +## Basic Usage -##### Simple Example ```php use Melbahja\Seo\Sitemap; -$sitemap = new Sitemap('https://example.com', ['save_path' => '/path/to_save/files']); +$sitemap = new Sitemap( + baseUrl: 'https://example.com', + saveDir: '/path/to_save/files', +); $sitemap->links('blog.xml', function($map) { - $map->loc('/blog')->freq('daily')->priority('0.8') - ->loc('/blog/my-new-article')->freq('weekly')->lastMod('2019-03-01') - ->loc('/اهلا-بالعالم')->freq('weekly'); - $map->loc('/blog/hello')->freq('monthly'); + $map->loc('/blog') + ->changeFreq('daily') + ->priority(0.8) + ->loc('/blog/my-new-article') + ->changeFreq('weekly') + ->lastMod('2024-01-15') + ->loc('/اهلا-بالعالم') + ->changeFreq('weekly'); + + $map->loc('/blog/hello')->changeFreq('monthly'); }); -// return bool -// throws SitemapException if save_path options not exists -$sitemap->save(); +$sitemap->render(); ``` -**Results:** (📂 in: /path/to_save/files/) - -📁: sitemap.xml (formatted) -```xml - - - - - https://example.com/blog.xml - 2019-03-01T14:38:02+01:00 - - -``` +## Options -📁: blog.xml (formatted) -```xml - - - - - https://example.com/blog - daily - 0.8 - - - https://example.com/blog/my-new-article - weekly - 2019-03-01T00:00:00+01:00 - - - https://example.com/%D8%A7%D9%87%D9%84%D8%A7-%D8%A8%D8%A7%D9%84%D8%B9%D8%A7%D9%84%D9%85 - weekly - - - https://example.com/blog/hello - monthly - - -``` +| Option | Description | Required | Default | +| --- | --- | --- | --- | +| `saveDir` | Generated sitemaps storage path | Yes | \- | +| `sitemapBaseUrl` | Custom URL for generated sitemaps | No | Base URL | +| `indexName` | Custom sitemap index name | No | sitemap.xml | +| `mode` | Output mode (FILE, MEMORY, STREAM, TEMP) | No | TEMP | -##### Multipe Sitemaps && Images -```php -use Melbahja\Seo\Sitemap; +## URL Methods -$sitemap = new Sitemap('https://example.com'); +```php +$builder->loc('/page') // URL path relative or absolute + ->priority(0.8) // Priority 0.0-1.0 + ->changeFreq('weekly') // always, hourly, daily, weekly, monthly, yearly, never + ->lastMod('2024-01-15') // Last modified date in string or unix ts + ->image('/image.jpg') // Add image (requires 'images' => true) + ->video('Title', [...]) // Add video (requires 'videos' => true) + ->alternate('/es/page', 'es'); // Add hreflang alternate +``` -// Instead of passing save_path to the factory you can set it later via setSavePath -// also $sitemap->getSavePath() method to get the current save_path -$sitemap->setSavePath('your_save/path'); +## Advanced Features -// changing sitemap index name -$sitemap->setIndexName('index.xml'); +### Image Sitemaps -// For images you need to pass a option images => true -$sitemap->links(['name' => 'blog.xml', 'images' => true], function($map) +```php +$sitemap->links(['name' => 'gallery.xml', 'images' => true], function($builder) { - $map->loc('/blog')->freq('daily')->priority('0.8') - ->loc('/blog/my-new-article') - ->freq('weekly') - ->lastMod('2019-03-01') - ->image('/uploads/image.jpeg', ['caption' => 'My caption']) - ->loc('/اهلا-بالعالم')->freq('weekly'); - - // image(string $url, array $options = []), image options: caption, geo_location, title, license - // see References -> images - $map->loc('/blog/hello')->freq('monthly')->image('https://cdn.example.com/image.jpeg'); + $builder->loc('/gallery/1') + ->image('/images/photo1.jpg', [ + 'title' => 'Photo Title', + 'caption' => 'Photo caption' + ]); }); +``` + +### Video Sitemaps -// another file -$sitemap->links('blog_2.xml', function($map) +```php +$sitemap->links(['name' => 'videos.xml', 'videos' => true], function($builder) { - // Mabye you need to loop through posts form your database ? - foreach (range(0, 4) as $i) - { - $map->loc("/posts/{$i}")->freq('weekly')->priority('0.7'); - } + $builder->loc('/video/page') + ->video('Video Title', [ + 'thumbnail' => '/thumb.jpg', + 'description' => 'Video description', + 'content_loc' => '/video.mp4' + ]); }); - -$sitemap->save(); - ``` -**Results** - -📁: index.xml -```xml - - - - - https://example.com/blog.xml - 2019-03-01T15:13:22+01:00 - - - https://example.com/blog_2.xml - 2019-03-01T15:13:22+01:00 - - +### News Sitemaps -``` +```php +use Melbahja\Seo\Sitemap\NewsBuilder; -📁: blog.xml -```xml - - - - - https://example.com/blog - daily - 0.8 - - - https://example.com/blog/my-new-article - weekly - 2019-03-01T00:00:00+01:00 - - My caption - https://example.com/uploads/image.jpeg - - - - https://example.com/%D8%A7%D9%87%D9%84%D8%A7-%D8%A8%D8%A7%D9%84%D8%B9%D8%A7%D9%84%D9%85 - weekly - - - https://example.com/blog/hello - monthly - - https://cdn.example.com/image.jpeg - - - +$sitemap->news('news.xml', function(NewsBuilder $builder) +{ + $builder->setPublication('Your News', 'en'); + + $builder->loc('/article/1') + ->news([ + 'title' => 'Article Title', + 'publication_date' => '2024-01-15T10:00:00Z', + 'keywords' => 'news, breaking' + ]); +}); ``` -📁: blog_2.xml -```xml - - - - - https://example.com/posts/0 - weekly - 0.7 - - - https://example.com/posts/1 - weekly - 0.7 - - - https://example.com/posts/2 - weekly - 0.7 - - - https://example.com/posts/3 - weekly - 0.7 - - - https://example.com/posts/4 - weekly - 0.7 - - -``` +### Multilingual Sitemaps -##### Sitemap with videos ```php -$sitemap = (new Sitemap('https://example.com')) - ->setSavePath('./storage/sitemaps') - ->setSitemapsUrl('https://example.com/sitemaps') - ->setIndexName('index.xml'); - -$sitemap->links(['name' => 'posts.xml', 'videos' => true], function($map) +$sitemap->links(['name' => 'multilang.xml', 'localized' => true], function($builder) { - $map->loc('/posts/clickbait-video')->video('My Clickbait Video title', - [ - // or thumbnail_loc - 'thumbnail' => 'https://example.com/thumbnail.jpeg', - 'description' => 'My description', - // player_loc or content_loc one of them is required - 'player_loc' => 'https://example.com/embed/81287127' - - // for all available options see References -> videos - ]); - - $map->loc('posts/bla-bla'); + $builder->loc('/page') + ->alternate('/es/page', 'es') + ->alternate('/fr/page', 'fr'); }); - -$sitemap->save(); -``` -**Results** - -📁: index.xml -```xml - - - - - https://example.com/sitemaps/posts.xml - 2019-03-01T15:30:02+01:00 - - -``` -**Note:** lastmod in sitemap index files are generated automatically - -📁: posts.xml -```xml - - - - - https://example.com/posts/clickbait-video - - My description - https://example.com/embed/81287127 - My Clickbait Video title - https://example.com/thumbnail.jpeg - - - - https://example.com/posts/bla-bla - - ``` -##### News Sitemaps +## Output Modes -```php -use Melbahja\Seo\Factory; +### TEMP Mode (Default) -$sitemap = Factory::sitemap('https://example.com', +```php +$sitemap = new Sitemap('https://example.com', [ - // You can also customize your options by passing array to the factory like this - 'save_path' => './path', - 'sitemaps_url' => 'https://example.com/maps', - 'index_name' => 'news_index.xml' + 'saveDir' => './storage', + 'mode' => OutputMode::TEMP ]); +$sitemap->render(); // Saves to temp dir and save to disk only on generation success. +``` -$sitemap->news('my_news.xml', function($map) -{ - // publication: name, language - // Google quote about the name: "It must exactly match the name as - // it appears on your articles on news.google.com" - $map->setPublication('PHP NEWS', 'en'); - - $map->loc('/news/12')->news( - [ - 'title' => 'PHP 8 Released', - 'publication_date' => '2019-03-01T15:30:02+01:00', - ]); - - $map->loc('/news/13')->news( - [ - 'title' => 'PHP 8 And High Performance', - 'publication_date' => '2019-04-01T15:30:02+01:00' - ]); -}); +### File Mode -$sitemap->save(); +```php +$sitemap = new Sitemap('https://example.com', +[ + 'saveDir' => './storage', + 'mode' => OutputMode::FILE +]); +$sitemap->render(); // Saves to disk ``` -**Results** - -📁: news_index.xml -```xml - - - - - https://example.com/maps/my_news.xml - 2019-03-01T15:57:10+01:00 - - -``` +### Memory Mode -📁: my_news.xml -```xml - - - - - https://example.com/news/12 - - - PHP NEWS - en - - PHP 8 Released - 2019-03-01T15:30:02+01:00 - - - - https://example.com/news/13 - - - PHP NEWS - en - - PHP 8 And High Performance - 2019-04-01T15:30:02+01:00 - - - +```php +$sitemap = new Sitemap('https://example.com', [ + 'mode' => OutputMode::MEMORY +]); +$xml = $sitemap->render(); // Returns XML string ``` -**Google quote:** ⚠ "If you submit your News sitemap before your site has been reviewed and approved by our team, you may receive errors." ⚠ +### Stream Mode +```php +$stream = fopen('sitemap.xml', 'w'); +$builder = new LinksBuilder( + baseUrl: 'https://example.com', + stream: $stream, // defaults to stdout + mode: OutputMode::STREAM, +); +$builder->loc('/page')->render(); +fclose($stream); +``` -#### 🤖 Send Sitemaps To Search Engines - -According to the sitemaps protocol, search engines should have a url that allow you to inform them about your new sitemap files. like: /ping?sitemap=sitemap_url +## Complete Example ```php -use Melbahja\Seo\Ping; +$sitemap = new Sitemap(baseUrl: 'https://example.com', options: [ + 'saveDir' => './sitemaps', + 'indexName' => 'sitemap-index.xml' +]); -$ping = new Ping; +// Regular pages y can just pass array of links +$sitemap->links('pages.xml', ['/', '/about', '/contact']); -// the void method send() will inform via CURL: google, bing and yandex about your new file -$ping->send('https://example.com/sitemap_file.xml'); +// Products with images +$sitemap->links(['name' => 'products.xml', 'images' => true], function($builder) +{ + $builder->loc('/product/123') + ->priority(0.9) + ->image('/product-main.jpg', ['title' => 'Product Image']); +}); +// News section +$sitemap->news('news.xml', function($builder) +{ + $builder->setPublication('Tech News', 'en'); + $builder->loc('/article/1') + ->news(['title' => 'New Article', 'publication_date' => date('c')]); +}); + +// Generate everything +$sitemap->render(); +// Creates: sitemap-index.xml, pages.xml, products.xml, news.xml ``` ### Indexing API -This is the first PHP library to support the new search engines indexing API (aka indexnow.org). +Submit URLs to search engines for instant indexing using Google Indexing API and IndexNow protocol. + +#### Google Indexing API ```php -use Melbahja\Seo\Indexing; +use Melbahja\Seo\Indexing\GoogleIndexer; +use Melbahja\Seo\Indexing\URLIndexingType; + +$indexer = new GoogleIndexer('your-google-access-token'); + +// Index single URL +$indexer->submitUrl('https://www.example.com/page'); -$indexer = new Indexing('www.example.cpm', [ - 'bing.com' => 'your_api_key_here', - 'yandex.com' => 'your_api_key_here', +// Index multiple URLs +$indexer->submitUrls([ + 'https://www.example.com/page1', + 'https://www.example.com/page2' ]); +// Delete URL from index +$indexer->submitUrl('https://www.example.com/deleted-page', URLIndexingType::DELETE); +``` + +#### IndexNow Protocol + +```php +use Melbahja\Seo\Indexing\IndexNowIndexer; -// index single url. -$indexer->indexUrl('https://www.example.com/page'); +$indexer = new IndexNowIndexer('your-indexnow-api-key'); -// index multi urls. -$indexer->indexUrls(['https://www.example.com/page']); +// Submit to all supported engines +$indexer->submitUrl('https://www.example.com/page'); +// Submit multiple URLs +$indexer->submitUrls([ + 'https://www.example.com/page1', + 'https://www.example.com/page2' +]); ``` +## AI LLMs.txt Support + +LLMs.txt isn't an established industry standard (IMO training honypot), it's a newer format designed mainly to help bigtech companies train their AI models. from a SEO perspective I don't see clear benefits for webmasters at this time. if you find LLMs.txt valuable for your use case, contributions are welcome! feel free to submit a PR. + +## Documentation +the docs are coming soon with more features and complete examples. + ## Sponsors Special thanks to friends who support this work financially: @@ -563,4 +441,4 @@ Special thanks to friends who support this work financially: ## License -[MIT](https://github.com/melbahja/seo/blob/master/LICENSE) Copyright (c) 2019-present Mohamed Elbahja +[MIT](https://github.com/melbahja/seo/blob/master/LICENSE) Copyright (c) Mohamed Elbahja diff --git a/composer.json b/composer.json index 7f567de..be19429 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,23 @@ { "name": "melbahja/seo", "type": "library", - "description": "Simple PHP library to help developers 🍻 do better on-page SEO optimization", + "description": "SEO library for PHP is a simple PHP library to help developers 🍻 do better on-page SEO optimizations.", "keywords": [ "seo", "search engine optimization", - "php7", + "php8", "schema.org", "sitemaps", "sitemap.xml", "sitemap index", "meta tags", "open graph", - "twitter tags" + "twitter tags", + "rich results", + "images sitemaps", + "video sitemaps", + "index sitemaps", + "news sitemaps" ], "license": "MIT", "authors": [ @@ -34,11 +39,6 @@ "Melbahja\\Seo\\": "src/" } }, - "autoload-dev": { - "psr-4":{ - "Tests\\Melbahja\\Seo\\": "tests/" - } - }, "require-dev": { "phpunit/phpunit": "^10.0" },