From 634d70f3be3270db9d68af2145b2088c36fe2918 Mon Sep 17 00:00:00 2001 From: Quentin Gabriele <40128136+QuentinGab@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:11:20 +0200 Subject: [PATCH] Refactoring SchemaCollection to allow any kind of schema + internal code simplification (#81) * refactor schema, tags and internal logic * fix tag attributes order * update readme and remove FAQPage * fix readme * fix readme * remove useless code * install pint and format * WIP keep using fluent methods now based on CustomSchema class * Fix tests * Style * Update README.md * Update README.md * Update README.md * Rename * Update CustomSchemaFluent.php * WIP * Delete test-results --------- Co-authored-by: Ralph J. Smit <59207045+ralphjsmit@users.noreply.github.com> --- .phpunit.cache/test-results | 1 - README.md | 130 +++++-- composer.json | 84 ++--- src/Schema/ArticleSchema.php | 16 +- src/Schema/BreadcrumbListSchema.php | 14 +- src/Schema/CustomSchema.php | 26 ++ src/Schema/CustomSchemaFluent.php | 36 ++ src/Schema/FaqPageSchema.php | 27 +- src/Schema/Schema.php | 49 --- src/SchemaCollection.php | 18 +- src/Support/AlternateTag.php | 4 +- src/Support/LinkTag.php | 6 +- src/Support/MetaContentTag.php | 6 +- src/Support/MetaTag.php | 6 +- src/Support/OpenGraphTag.php | 7 +- src/Support/SchemaTagCollection.php | 18 +- src/Support/SitemapTag.php | 13 +- src/Support/Tag.php | 45 ++- src/Support/TwitterCardTag.php | 7 +- src/TagCollection.php | 2 +- src/Tags/DescriptionTag.php | 2 +- src/Tags/TitleTag.php | 5 +- tests/Feature/JSON-LD/ArticleTest.php | 40 +- tests/Feature/JSON-LD/BreadcrumbListTest.php | 58 +-- tests/Feature/JSON-LD/FaqPageTest.php | 38 +- .../Feature/JSON-LD/SchemaCollectionTest.php | 342 ++++++++++++++++++ tests/Feature/Tags/AlternateTagTest.php | 4 +- tests/Feature/Tags/SitemapTagTest.php | 2 +- tests/Unit/Schema/ArticleSchemaTest.php | 84 ++--- tests/Unit/Schema/CustomSchemaTest.php | 50 +++ tests/Unit/Support/TagTest.php | 26 ++ 31 files changed, 846 insertions(+), 320 deletions(-) delete mode 100644 .phpunit.cache/test-results create mode 100644 src/Schema/CustomSchema.php create mode 100644 src/Schema/CustomSchemaFluent.php delete mode 100644 src/Schema/Schema.php create mode 100644 tests/Feature/JSON-LD/SchemaCollectionTest.php create mode 100644 tests/Unit/Schema/CustomSchemaTest.php create mode 100644 tests/Unit/Support/TagTest.php diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results deleted file mode 100644 index a4fbe1c..0000000 --- a/.phpunit.cache/test-results +++ /dev/null @@ -1 +0,0 @@ -{"version":"pest_2.34.2","defects":[],"times":{"P\\Tests\\Unit\\Support\\HasSEOTest::__pest_evaluable_it_automatically_associates_a_SEO_model_on_creation":0.017,"P\\Tests\\Feature\\JSONLD\\ArticleTest::__pest_evaluable_it_can_correctly_render_the_JSON_LD_Schema_markup__Article":0.033,"P\\Tests\\Feature\\JSONLD\\ArticleTest::__pest_evaluable_it_does_not_render_by_default_the_JSON_LD_Schema_markup__Article":0.002,"P\\Tests\\Feature\\Tags\\CanonicalTagTest::__pest_evaluable_it_can_display_the_canonical_URL_if_allowed":0.001,"P\\Tests\\Feature\\Tags\\CanonicalTagTest::__pest_evaluable_it_can_display_the_model_level_canonical_url_if_set_on_override":0.002,"P\\Tests\\Feature\\Tags\\CanonicalTagTest::__pest_evaluable_it_cannot_display_the_canonical_url_if_not_allowed":0.001,"P\\Tests\\Feature\\Tags\\CanonicalTagTest::__pest_evaluable_it_will_not_break_if_no_canonical__url_column_exists_in_seo_table":0.003,"P\\Tests\\Feature\\Tags\\CanonicalTagTest::__pest_evaluable_it_can_display_the_model_level_canonical_url_if_set_in_database":0.002,"P\\Tests\\Unit\\Facades\\SEOManagerTest::__pest_evaluable_the_SEOManager_facade_works_as_expected":0,"P\\Tests\\Unit\\Schema\\ArticleSchemaTest::__pest_evaluable_it_can_construct_Schema_Markup__Article":0,"P\\Tests\\Unit\\Schema\\ArticleSchemaTest::__pest_evaluable_it_can_add_multiple_authors_to_Schema_Markup__Article":0,"P\\Tests\\Unit\\Support\\SEODataTest::__pest_evaluable_it_can_determine_the_size_of_an_image":0.001,"P\\Tests\\Unit\\Support\\SEODataTest::__pest_evaluable_it_can_allow_to_prepareForUsage_without_a_model_in_the_database":0.001,"P\\Tests\\Feature\\Tags\\ImageTagTest::__pest_evaluable_it_will_display_the_image_url_if_it_came_from_a_model":0.002,"P\\Tests\\Feature\\Tags\\ImageTagTest::__pest_evaluable_it_will_not_render_the_default_image_if_that_was_disabled":0.001,"P\\Tests\\Feature\\Tags\\ImageTagTest::__pest_evaluable_it_will_display_the_image_url_from_a_model":0.002,"P\\Tests\\Feature\\Tags\\ImageTagTest::__pest_evaluable_it_will_render_the_default_image#('\/public\/test\/image.jpg')":0.001,"P\\Tests\\Feature\\Tags\\ImageTagTest::__pest_evaluable_it_will_render_the_default_image#('public\/test\/image.jpg')":0.001,"P\\Tests\\Feature\\Tags\\SitemapTagTest::__pest_evaluable_it_can_display_the_sitemap_if_path_is_set":0.002,"P\\Tests\\Unit\\Schema\\BreadcrumbListSchemaTest::__pest_evaluable_it_can_construct_Schema_Markup__BreadcrumbList":0,"P\\Tests\\Feature\\Tags\\AuthorTagTest::__pest_evaluable_it_can_override_the_author":0.002,"P\\Tests\\Feature\\Tags\\AuthorTagTest::__pest_evaluable_it_can_display_the_fallback_description_tag":0.001,"P\\Tests\\Feature\\Tags\\AuthorTagTest::__pest_evaluable_it_will_not_display_the_author_tag_if_there_isn_t_a_author":0.001,"P\\Tests\\Feature\\Tags\\AuthorTagTest::__pest_evaluable_it_will_display_the_author_if_the_associated_SEO_model_has_a_author":0.002,"P\\Tests\\Feature\\JSONLD\\BreadcrumbListTest::__pest_evaluable_it_does_not_render_by_default_the_JSON_LD_Schema_markup__BreadcrumbList":0.001,"P\\Tests\\Feature\\JSONLD\\BreadcrumbListTest::__pest_evaluable_it_can_correctly_render_the_JSON_LD_Schema_markup__BreadcrumbList":0.002,"P\\Tests\\Feature\\Tags\\DescriptionTagTest::__pest_evaluable_it_will_display_the_description_if_the_associated_SEO_model_has_a_description":0.002,"P\\Tests\\Feature\\Tags\\DescriptionTagTest::__pest_evaluable_it_can_display_the_fallback_description_tag":0.002,"P\\Tests\\Feature\\Tags\\DescriptionTagTest::__pest_evaluable_it_will_not_display_the_description_tag_if_there_isn_t_a_description":0.001,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_will_not_include_the_widths_and_heights_of_Twitter_images_if_the_image_was_overridden_using_a_URL#('summary', 'images\/twitter-1743x1743.jpg', '1743', \u2026)":0.003,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_will_not_include_the_widths_and_heights_of_Twitter_images_if_the_image_was_overridden_using_a_URL#('summary_large_image', 'images\/twitter-3597x1799.jpg', '3597', \u2026)":0.003,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_can_correctly_render_the_Twitter_Card_summary_with_the_image_on_a_Page#('summary_large_image', 'images\/twitter-3597x1799.jpg', '3597', \u2026)":0.003,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_can_correctly_render_the_Twitter_Card_summary_with_the_image_on_a_Page#('summary', 'images\/twitter-1743x1743.jpg', '1743', \u2026)":0.003,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_uses_openGraphTitle_over_title":0.003,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_will_not_render_the_Twitter_Card_summary__large__image_for_too_large_or_small_images#('images\/twitter-72x72.jpg')":0.002,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_will_not_render_the_Twitter_Card_summary__large__image_for_too_large_or_small_images#('images\/twitter-4721x4721.jpg')":0.002,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_will_escape_the_title":0.003,"P\\Tests\\Feature\\Tags\\TwitterCardSummaryTagsTest::__pest_evaluable_it_can_correctly_render_the_Twitter_Card_summary_without_the_image":0.002,"P\\Tests\\Feature\\Tags\\FaviconTagTest::__pest_evaluable_it_will_render_the_favicon_if_the_favicon_is_set":0.001,"P\\Tests\\Feature\\Tags\\FaviconTagTest::__pest_evaluable_it_will_not_render_the_favicon_if_the_favicon_is_set_to_null":0.001,"P\\Tests\\Unit\\SEOManagerTest::__pest_evaluable_the_SEOManager_singleton_works_as_expected":0,"P\\Tests\\Feature\\Tags\\AlternateTagTest::__pest_evaluable_it_will_not_display_the_aternates_tags_if_there_isn_t_any_alternate":0.001,"P\\Tests\\Feature\\Tags\\AlternateTagTest::__pest_evaluable_it_will_display_the_alternates_links_if_the_associated_SEO_model_has_alternates_links":0.002,"P\\Tests\\Unit\\HelpersTest::__pest_evaluable_it_can_get_the_TagManager_with_a_SEOData_data_object":0.001,"P\\Tests\\Unit\\HelpersTest::__pest_evaluable_it_can_get_the_TagManager":0,"P\\Tests\\Unit\\HelpersTest::__pest_evaluable_it_can_get_the_TagManager_with_and_without_a_model":0.001,"P\\Tests\\Feature\\Tags\\TitleTagTest::__pest_evaluable_it_will_infer_the_title_from_the_url_if_that_is_allowed":0.002,"P\\Tests\\Feature\\Tags\\TitleTagTest::__pest_evaluable_it_will_escape_the_title":0.001,"P\\Tests\\Feature\\Tags\\TitleTagTest::__pest_evaluable_it_will_display_the_title_if_the_associated_SEO_model_has_a_title":0.002,"P\\Tests\\Feature\\Tags\\TitleTagTest::__pest_evaluable_it_will_infer_the_title_from_the_url_if_that_is_allowed_and_the_model_doesn_t_return_a_title":0.002,"P\\Tests\\Feature\\Tags\\TitleTagTest::__pest_evaluable_it_will_not_infer_the_title_from_the_url_if_that_isn_t_allowed":0.003,"P\\Tests\\Feature\\JSONLD\\FaqPageTest::__pest_evaluable_it_can_correctly_render_the_JSON_LD_Schema_markup__FaqPageTest":0.002,"P\\Tests\\Feature\\JSONLD\\FaqPageTest::__pest_evaluable_it_does_not_render_by_default_the_JSON_LD_Schema_markup__FaqPageTest":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_add_properties_to_a_SEO_model#('description', 'This is a description')":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_add_properties_to_a_SEO_model#('title', 'My Cool Page Title')":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_have_immutable_timestamps":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_override_certain_SEO_Data#('title', 'My Cool Page Title')":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_override_certain_SEO_Data#('description', 'This is a description')":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_morph_a_model_to_the_SEO_model":0,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_disable_the_suffix_in_the_page_model":0.002,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_give_the_title_of_a_page_a_suffix_it_was_specified":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_disable_the_suffix_in_the_page_model_dynamically_via_a_function":0.001,"P\\Tests\\Unit\\Models\\SEOTest::__pest_evaluable_it_can_prepare_the_SEO_for_use_on_a_page":0.001,"P\\Tests\\Feature\\Tags\\RobotsTagTest::__pest_evaluable_it_cannot_overwrite_the_robots_tag__default__value_with_the_robots_attribute_if__force__default__is_set__DB_Model_":0.007,"P\\Tests\\Feature\\Tags\\RobotsTagTest::__pest_evaluable_it_can_overwrite_the_robots_tag__default__value_with_the_robots_attribute__DB_Model_":0.002,"P\\Tests\\Feature\\Tags\\RobotsTagTest::__pest_evaluable_it_cannot_overwrite_the_robots_tag__default__value_with_the_robots_attribute_if__force__default__is_set__SEOData_":0.001,"P\\Tests\\Feature\\Tags\\RobotsTagTest::__pest_evaluable_it_can_overwrite_the_robots_tag__default__value_with_the_robots_attribute__SEOData_":0.001,"P\\Tests\\Feature\\Tags\\RobotsTagTest::__pest_evaluable_it_can_output_the_robots_tag__default__value":0.001,"P\\Tests\\Feature\\TagManagerTest::__pest_evaluable_it_can_replace_the_title_if_we_re_on_the_homepage#('Custom homepage title', 'Custom homepage title')":0.001,"P\\Tests\\Feature\\TagManagerTest::__pest_evaluable_it_can_replace_the_title_if_we_re_on_the_homepage#(null, '| My Website suffix')":0.001,"P\\Tests\\Feature\\TagManagerTest::__pest_evaluable_it_can_pipe_the_generated_tags_through_the_transformers_just_before_render":0.001,"P\\Tests\\Feature\\TagManagerTest::__pest_evaluable_it_can_pipe_the_SEOData_through_the_transformer_before_putting_it_into_the_collection":0.002,"P\\Tests\\Feature\\TagManagerTest::__pest_evaluable_can_render_the_SEOData_from_an_object_that_s_directly_passed_in":0.001,"P\\Tests\\Feature\\Tags\\OpenGraphTagsTest::__pest_evaluable_it_uses_openGraphTitle_over_title":0.003,"P\\Tests\\Feature\\Tags\\OpenGraphTagsTest::__pest_evaluable_it_can_correctly_render_OpenGraph_tags_for_a_post_or_page_with_a_few_additional_overrides":0.003,"P\\Tests\\Feature\\Tags\\OpenGraphTagsTest::__pest_evaluable_it_will_escape_the_title":0.002,"P\\Tests\\Feature\\Tags\\OpenGraphTagsTest::__pest_evaluable_it_can_correctly_render_OpenGraph_tags":0.002,"P\\Tests\\Feature\\Tags\\OpenGraphTagsTest::__pest_evaluable_it_can_correctly_render_OpenGraph_tags_for_a_post_or_page":0.003,"P\\Tests\\Feature\\Tags\\OpenGraphTagsTest::__pest_evaluable_it_can_correctly_render_locale_tags":0.001}} \ No newline at end of file diff --git a/README.md b/README.md index 2d7485e..fb64e48 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ Currently there aren't that many SEO-packages for Laravel and the available ones are quite complex to set up and very decoupled from the database. They only provided you with helpers to generate the tags, but you still had to use those helpers: nothing was generated automatically and they almost do not work out of the box. -This package generates **valid and useful meta tags straight out-of-the-box**, with limited initial configuration, whilst still providing a simple, but powerful API to work with. It can generate: +This package generates **valid and useful meta tags straight out-of-the-box**, with limited initial configuration, while still providing a simple, but powerful API to work with. It can generate: 1. Title tag (with sitewide suffix) 2. Meta tags (author, description, image, robots, etc.) 3. OpenGraph Tags (Facebook, LinkedIn, etc.) 4. Twitter Tags -5. Structured data (Article, Breadcrumbs and FAQPage) +5. Structured data (Article, Breadcrumbs, FAQPage, or any custom schema) 6. Favicon 7. Robots tag 8. Alternates links tag @@ -100,7 +100,7 @@ return [ /** * Use this setting to specify whether you want self-referencing `` tags to - * be added to the head of every page. There has been some debate whether this a good practice, but experts + * be added to the head of every page. There has been some debate whether this is a good practice, but experts * from Google and Yoast say that this is the best strategy. * See https://yoast.com/rel-canonical/. */ @@ -128,7 +128,7 @@ return [ /** * Use this setting to specify the path to the favicon for your website. The url to it will be generated using the `secure_url()` function, - * so make sure to make the favicon accessibly from the `public` folder. + * so make sure to make the favicon accessible from the `public` folder. * * You can use the following filetypes: ico, png, gif, jpeg, svg. */ @@ -140,7 +140,7 @@ return [ * was given. This will be very useful on pages where you don't have an Eloquent model for, or where you * don't want to hardcode the title. * - * For example, if you have a with the url '/foo/about-me', we'll automatically set the title to 'About me' and append the site suffix. + * For example, if you have an url with the path '/foo/about-me', we'll automatically set the title to 'About me' and append the site suffix. */ 'infer_title_from_url' => true, @@ -277,12 +277,12 @@ You are allowed to only override the properties you want and omit the other prop 5. `url` (by default it will be `url()->current()`) 6. `enableTitleSuffix` (should be `true` or `false`, this allows you to set a suffix in the `config/seo.php` file, which will be appended to every title) 7. `site_name` -8. `published_time` (should be a `Carbon` instance with the published time. By default this will be the `created_at` property of your model) -9. `modified_time` (should be a `Carbon` instance with the published time. By default this will be the `updated_at` property of your model) +8. `published_time` (should be a `Carbon` instance with the published time. By default, this will be the `created_at` property of your model) +9. `modified_time` (should be a `Carbon` instance with the published time. By default, this will be the `updated_at` property of your model) 10. `section` (should be the name of the section of your content. It is used for OpenGraph article tags and it could be something like the category of the post) 11. `tags` (should be an array with tags. It is used for the OpenGraph article tags) 12. `schema` (this should be a SchemaCollection instance, where you can configure the JSON-LD structured data schema tags) -13. `locale` (this should be the locale of the page. By default this is derived from `app()->getLocale()` and it looks like `en` or `nl`.) +13. `locale` (this should be the locale of the page. By default, this is derived from `app()->getLocale()` and it looks like `en` or `nl`.) 14. `robots` (should be a string with the content value of the robots meta tag, like `nofollow,noindex`). You can also use the `$SEOData->markAsNoIndex()` to prevent a page from being indexed. 15. `alternates` (should be an array of `AlternateTag`). Will render `` tags. @@ -329,19 +329,57 @@ class Homepage extends Controller ## Generating JSON-LD structured data -This package can also **generate structured data** for you (also called schema markup). At the moment we support the following types: +This package can also **generate any structured data** for you (also called schema markup). +Structured data is a very vast subject, so we highly recommend you to check the [Google documentation dedicated to it](https://developers.google.com/search/docs/appearance/structured-data/search-gallery). + +Structured data can be added in two ways: +- Construct custom arrays of the structured data format, which is then rendered by the package in JSON on the correct place. +- Use one of the 3 pre-defined templates to fluently build your structured data (`Article`, `BreadcrumbList`, `FaqPage`). + +### Adding your first schema + +Let's add the FAQPage schema markup to our website as an example: + +```php +use RalphJSmit\Laravel\SEO\SchemaCollection; + +public function getDynamicSEOData(): SEOData +{ + return new SEOData( + // ... + schema: SchemaCollection::make() + ->add(fn (SEOData $SEOData) => [ + // You could use the `$SEOData` to dynamically + // fetch any data about the current page. + '@context' => 'https://schema.org', + '@type' => 'FAQPage', + 'mainEntity' => [ + '@type' => 'Question', + 'name' => 'Your question goes here', + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => 'Your answer goes here', + ], + ], + ]), + ); +} +``` + +> [!TIP] +> When adding a new schema, you can check the [documentation here](https://developers.google.com/search/docs/appearance/structured-data/faqpage) to know what keys to add. + +### Pre-configured Schema: Article and BreadcrumbList + +To help you get started with structured data, we added 3 preconfigured schema that you can construct using fluent methods. The following types are available: 1. `Article` 2. `BreadcrumbList` 3. `FAQPage` -After generating the structured data it is always a good idea to [test your website with Google's rich result validator](https://search.google.com/test/rich-results). - -However, you can easily send me a (draft) PR with your requested types and I'll (most probably) add them to the package. - ### Article schema markup -To enable structured data, you need to use the `schema` property of the `SEOData` class. To generate `Article` schema markup, use the `->addArticle()` method: +In order to automatically and fluently generate `Article` schema markup, use the `->addArticle()` method: ```php @@ -351,40 +389,54 @@ public function getDynamicSEOData(): SEOData { return new SEOData( // ... - schema: SchemaCollection::initialize()->addArticle(), + schema: SchemaCollection::make()->addArticle(), ); } ``` - -You can pass a closure the the `->addArticle()` method to customize the individual schema markup. This closure will receive an instance of `ArticleSchema` as its argument. You can an additional author by using the `->addAuthor()` method: + +This will construct an article schema using all data provided by the `SEOData` object. You can pass a closure to `->addArticle()` method to customize the individual schema markup. This closure will receive an instance of ArticleSchema as its argument. You can an additional author by using the `->addAuthor()` method. ```php -SchemaCollection::initialize()->addArticle( - fn (ArticleSchema $article): ArticleSchema => $article->addAuthor('Second author') -); +use RalphJSmit\Laravel\SEO\Schema\ArticleSchema; +use RalphJSmit\Laravel\SEO\SchemaCollection; +use RalphJSmit\Laravel\SEO\Support\SEOData; +use Illuminate\Support\Collection; + +public function getDynamicSEOData(): SEOData +{ + return new SEOData( + // ... + title: "A boring title" + schema: SchemaCollection::make() + ->addArticle(function (ArticleSchema $article, SEOData $SEOData): ArticleSchema { + return $article->addAuthor($this->moderator); + }), + ); +} ``` You can completely customize the schema markup by using the `->markup()` method on the `ArticleSchema` instance: ```php -use Illuminate\Support\Collection; - -SchemaCollection::initialize()->addArticle(function (ArticleSchema $article): ArticleSchema { - return $article->markup(function(Collection $markup): Collection { - return $markup->put('alternativeHeadline', $this->tagline); +SchemaCollection::initialize()->addArticle(function (ArticleSchema $article, SEOData $SEOData): ArticleSchema { + return $article->markup(function (Collection $markup) use ($SEOData): Collection { + return $markup->put('alternativeHeadline', "Not {$SEOData->title}"); // Set/overwrite alternative headline property to `Will be "Not A boring title"` :) }); }); ``` -At this point, I'm just unable to fluently support every possible version of the structured, so this is the perfect way to add an additional property to the output! +> [!TIP] +> Check the Google documentation about [Article](https://developers.google.com/search/docs/appearance/structured-data/article) for more information. ### BreadcrumbList schema markup -You can also add `BreadcrumbList` schema markup by using the `->addBreadcrumbs()` function on the `SchemaCollection`: +You can also add `BreadcrumbList` schema markup by using the `->addBreadcrumbList()` function on the `SchemaCollection`. + +By default, the schema will only contain the current url from `$SEOData->url`. ```php SchemaCollection::initialize() - ->addBreadcrumbs(function (BreadcrumbListSchema $breadcrumbs): BreadcrumbListSchema { + ->addBreadcrumbs(function (BreadcrumbListSchema $breadcrumbs, SEOData $SEOData): BreadcrumbListSchema { return $breadcrumbs ->prependBreadcrumbs([ 'Homepage' => 'https://example.com', @@ -393,7 +445,7 @@ SchemaCollection::initialize() ->appendBreadcrumbs([ 'Subarticle' => 'https://example.com/test/article/2', ]) - ->markup(function(Collection $markup): Collection { + ->markup(function (Collection $markup): Collection { // ... }); }); @@ -406,25 +458,31 @@ This code will generate `BreadcrumbList` JSON-LD structured data with the follow 3. [Current page] 4. Subarticle +> [!TIP] +> Check the Google documentation about [BreadcrumbList](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb) for more information. + ### FAQPage schema markup -You can also add `FAQPage` schema markup by using the `->addFaqPage()` function on the `SchemaCollection`: +You can also add FAQPage schema markup by using the ->addFaqPage() function on the SchemaCollection: ```php -use RalphJSmit\Laravel\SEO\Schema\FaqPageSchema; -use RalphJSmit\Laravel\SEO\SchemaCollection; - SchemaCollection::initialize() - ->addFaqPage(function (FaqPageSchema $faqPage): FaqPageSchema { + ->addFaqPage(function (FaqPageSchema $faqPage, SEOData $SEOData): FaqPageSchema { return $faqPage ->addQuestion(name: "Can this package add FaqPage to the schema?", acceptedAnswer: "Yes!") ->addQuestion(name: "Does it support multiple questions?", acceptedAnswer: "Of course."); }); ``` +> [!TIP] +> Check the Google documentation about [Faq Page](https://developers.google.com/search/docs/appearance/structured-data/faqpage) for more information. + +> [!TIP] +> After generating the structured data, it is always a good idea to [test your website with Google's rich result validator](https://search.google.com/test/rich-results). + ## Advanced usage -Sometimes you may have advanced needs, that require you to apply your own logic to the `SEOData` class, just before it is used to generate the tags. +Sometimes you may have advanced needs that require you to apply your own logic to the `SEOData` class, just before it is used to generate the tags. To accomplish this, you can use the `SEODataTransformer()` function on the `SEOManager` facade to register one or multiple closures that will be able to modify the `SEOData` instance at the last moment: @@ -444,7 +502,7 @@ SEOManager::SEODataTransformer(function (SEOData $SEOData): SEOData { ### Modifying tags before they are rendered -You can also **register closures that can modify the final collection of generated tags**, right before they are rendered. This is useful if you want to add custom tags to the output, or if you want to modify the output of the tags. +You can also **register closures that can modify the final collection of generated tags**, right before they are rendered. This is useful if you want to add custom tags to the output or if you want to modify the output of the tags. ```php SEOManager::tagTransformer(function (TagCollection $tags): TagCollection { diff --git a/composer.json b/composer.json index 4b81210..7cb26e0 100644 --- a/composer.json +++ b/composer.json @@ -1,70 +1,72 @@ { - "name" : "ralphjsmit/laravel-seo", - "description" : "A package to handle the SEO in any Laravel application, big or small.", - "keywords" : [ + "name": "ralphjsmit/laravel-seo", + "description": "A package to handle the SEO in any Laravel application, big or small.", + "keywords": [ "ralphjsmit", "laravel", "laravel-seo" ], - "homepage" : "https://github.com/ralphjsmit/laravel-seo", - "license" : "MIT", - "authors" : [ + "homepage": "https://github.com/ralphjsmit/laravel-seo", + "license": "MIT", + "authors": [ { - "name" : "Ralph J. Smit", - "email" : "rjs@ralphjsmit.com", - "role" : "Developer" + "name": "Ralph J. Smit", + "email": "rjs@ralphjsmit.com", + "role": "Developer" } ], - "require" : { + "require": { "php": "^8.0", "illuminate/contracts": "^9.0|^10.0|^11.0", "ralphjsmit/laravel-helpers": "^1.9", "spatie/laravel-package-tools": "^1.9.2" }, - "require-dev" : { - "nesbot/carbon" : "^2.66|^3.0", - "nunomaduro/collision" : "^5.10|^6.0|^7.0|^8.0", - "orchestra/testbench" : "^7.0|^8.0|^9.0", - "pestphp/pest" : "^1.21|^2.0", - "pestphp/pest-plugin-laravel" : "^1.1|^2.0", - "phpunit/phpunit" : "^9.5|^10.5", - "spatie/laravel-ray" : "^1.26", - "spatie/pest-plugin-test-time" : "^1.0|^2.0" + "require-dev": { + "laravel/pint": "^1.16", + "nesbot/carbon": "^2.66|^3.0", + "nunomaduro/collision": "^5.10|^6.0|^7.0|^8.0", + "orchestra/testbench": "^7.0|^8.0|^9.0", + "pestphp/pest": "^1.21|^2.0", + "pestphp/pest-plugin-laravel": "^1.1|^2.0", + "phpunit/phpunit": "^9.5|^10.5", + "spatie/laravel-ray": "^1.26", + "spatie/pest-plugin-test-time": "^1.0|^2.0" }, - "autoload" : { - "psr-4" : { - "RalphJSmit\\Laravel\\SEO\\" : "src", - "RalphJSmit\\Laravel\\SEO\\Database\\Factories\\" : "database/factories" + "autoload": { + "psr-4": { + "RalphJSmit\\Laravel\\SEO\\": "src", + "RalphJSmit\\Laravel\\SEO\\Database\\Factories\\": "database/factories" }, - "files" : [ + "files": [ "src/helpers.php" ] }, - "autoload-dev" : { - "psr-4" : { - "RalphJSmit\\Laravel\\SEO\\Tests\\" : "tests" + "autoload-dev": { + "psr-4": { + "RalphJSmit\\Laravel\\SEO\\Tests\\": "tests" } }, - "scripts" : { - "test" : "vendor/bin/pest", - "test-coverage" : "vendor/bin/pest --coverage" + "scripts": { + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" }, - "config" : { - "sort-packages" : true, - "allow-plugins" : { - "pestphp/pest-plugin" : true + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true } }, - "extra" : { - "laravel" : { - "providers" : [ + "extra": { + "laravel": { + "providers": [ "RalphJSmit\\Laravel\\SEO\\LaravelSEOServiceProvider" ], - "aliases" : { - "SEOManager" : "RalphJSmit\\Laravel\\SEO\\Facades\\SEOManager" + "aliases": { + "SEOManager": "RalphJSmit\\Laravel\\SEO\\Facades\\SEOManager" } } }, - "minimum-stability" : "dev", - "prefer-stable" : true + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/src/Schema/ArticleSchema.php b/src/Schema/ArticleSchema.php index f70587c..b7cea04 100644 --- a/src/Schema/ArticleSchema.php +++ b/src/Schema/ArticleSchema.php @@ -4,10 +4,9 @@ use Carbon\CarbonInterface; use Illuminate\Support\Collection; -use Illuminate\Support\HtmlString; use RalphJSmit\Laravel\SEO\Support\SEOData; -class ArticleSchema extends Schema +class ArticleSchema extends CustomSchemaFluent { public array $authors = []; @@ -29,7 +28,7 @@ class ArticleSchema extends Schema public function addAuthor(string $authorName): static { - if (empty($this->authors)) { + if (! $this->authors) { $this->authors = [ '@type' => 'Person', 'name' => $authorName, @@ -49,7 +48,7 @@ public function addAuthor(string $authorName): static return $this; } - public function initializeMarkup(SEOData $SEOData, array $markupBuilders): void + public function initializeMarkup(SEOData $SEOData): void { $this->url = $SEOData->url; @@ -76,9 +75,9 @@ public function initializeMarkup(SEOData $SEOData, array $markupBuilders): void } } - public function generateInner(): HtmlString + public function generateInner(): Collection { - $inner = collect([ + return collect([ '@context' => 'https://schema.org', '@type' => $this->type, 'mainEntityOfPage' => [ @@ -93,9 +92,6 @@ public function generateInner(): HtmlString ->when($this->description, fn (Collection $collection): Collection => $collection->put('description', $this->description)) ->when($this->image, fn (Collection $collection): Collection => $collection->put('image', $this->image)) ->when($this->articleBody, fn (Collection $collection): Collection => $collection->put('articleBody', $this->articleBody)) - ->pipeThrough($this->markupTransformers) - ->toJson(); - - return new HtmlString($inner); + ->pipeThrough($this->markupTransformers); } } diff --git a/src/Schema/BreadcrumbListSchema.php b/src/Schema/BreadcrumbListSchema.php index 7e48ed1..1bc522e 100644 --- a/src/Schema/BreadcrumbListSchema.php +++ b/src/Schema/BreadcrumbListSchema.php @@ -3,10 +3,9 @@ namespace RalphJSmit\Laravel\SEO\Schema; use Illuminate\Support\Collection; -use Illuminate\Support\HtmlString; use RalphJSmit\Laravel\SEO\Support\SEOData; -class BreadcrumbListSchema extends Schema +class BreadcrumbListSchema extends CustomSchemaFluent { public Collection $breadcrumbs; @@ -21,16 +20,16 @@ public function appendBreadcrumbs(array $breadcrumbs): static return $this; } - public function initializeMarkup(SEOData $SEOData, array $markupBuilders): void + public function initializeMarkup(SEOData $SEOData): void { $this->breadcrumbs = collect([ $SEOData->title => $SEOData->url, ]); } - public function generateInner(): HtmlString + public function generateInner(): Collection { - $inner = collect([ + return collect([ '@context' => 'https://schema.org', '@type' => $this->type, 'itemListElement' => $this->breadcrumbs @@ -43,10 +42,7 @@ public function generateInner(): HtmlString ]); }, new Collection()), ]) - ->pipeThrough($this->markupTransformers) - ->toJson(); - - return new HtmlString($inner); + ->pipeThrough($this->markupTransformers); } public function prependBreadcrumbs(array $breadcrumbs): static diff --git a/src/Schema/CustomSchema.php b/src/Schema/CustomSchema.php new file mode 100644 index 0000000..af87577 --- /dev/null +++ b/src/Schema/CustomSchema.php @@ -0,0 +1,26 @@ + 'application/ld+json', + ]; + + public function __construct(iterable | Arrayable $inner) + { + $this->inner = new HtmlString( + collect($inner)->toJson() + ); + } +} diff --git a/src/Schema/CustomSchemaFluent.php b/src/Schema/CustomSchemaFluent.php new file mode 100644 index 0000000..a9a7d71 --- /dev/null +++ b/src/Schema/CustomSchemaFluent.php @@ -0,0 +1,36 @@ +initializeMarkup($SEOData); + + // `$markupBuilders` are closures that modify this fluent schema + // tag object and can call methods on it to change items... + foreach ($markupBuilders as $markupBuilder) { + $markupBuilder($this, $SEOData); + } + + parent::__construct($this->generateInner()); + } + + abstract public function initializeMarkup(SEOData $SEOData): void; + + abstract public function generateInner(): Collection; + + public function markup(Closure $transformer): static + { + $this->markupTransformers[] = $transformer; + + return $this; + } +} diff --git a/src/Schema/FaqPageSchema.php b/src/Schema/FaqPageSchema.php index 4764d19..ad93c3c 100644 --- a/src/Schema/FaqPageSchema.php +++ b/src/Schema/FaqPageSchema.php @@ -2,22 +2,20 @@ namespace RalphJSmit\Laravel\SEO\Schema; -use Illuminate\Support\HtmlString; +use Illuminate\Support\Collection; use RalphJSmit\Laravel\SEO\Support\SEOData; /** * @see https://developers.google.com/search/docs/appearance/structured-data/faqpage */ -class FaqPageSchema extends Schema +class FaqPageSchema extends CustomSchemaFluent { - public string $type = 'FAQPage'; + public Collection $questions; - public array $questions = []; + public string $type = 'FAQPage'; - public function addQuestion( - string $name, - string $acceptedAnswer - ): static { + public function addQuestion(string $name, string $acceptedAnswer): static + { $this->questions[] = [ '@type' => 'Question', 'name' => $name, @@ -30,21 +28,18 @@ public function addQuestion( return $this; } - public function initializeMarkup(SEOData $SEOData, array $markupBuilders): void + public function initializeMarkup(SEOData $SEOData): void { - // + $this->questions = new Collection(); } - public function generateInner(): HtmlString + public function generateInner(): Collection { - $inner = collect([ + return collect([ '@context' => 'https://schema.org', '@type' => $this->type, 'mainEntity' => $this->questions, ]) - ->pipeThrough($this->markupTransformers) - ->toJson(); - - return new HtmlString($inner); + ->pipeThrough($this->markupTransformers); } } diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php deleted file mode 100644 index 265a9d3..0000000 --- a/src/Schema/Schema.php +++ /dev/null @@ -1,49 +0,0 @@ - 'application/ld+json', - ]; - - public string $context = 'https://schema.org/'; - - public Collection $markup; - - public array $markupTransformers = []; - - public string $tag = 'script'; - - public HtmlString $inner; - - public function __construct(SEOData $SEOData, array $markupBuilders = []) - { - $this->initializeMarkup($SEOData, $markupBuilders); - - $this->pipeThrough($markupBuilders); - - $this->inner = $this->generateInner(); - } - - abstract public function generateInner(): HtmlString; - - abstract public function initializeMarkup(SEOData $SEOData, array $markupBuilders): void; - - public function markup(Closure $transformer): static - { - $this->markupTransformers[] = $transformer; - - return $this; - } -} diff --git a/src/SchemaCollection.php b/src/SchemaCollection.php index 3d9c6cb..e647a23 100644 --- a/src/SchemaCollection.php +++ b/src/SchemaCollection.php @@ -3,39 +3,45 @@ namespace RalphJSmit\Laravel\SEO; use Closure; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Collection; use RalphJSmit\Laravel\SEO\Schema\ArticleSchema; use RalphJSmit\Laravel\SEO\Schema\BreadcrumbListSchema; use RalphJSmit\Laravel\SEO\Schema\FaqPageSchema; -use RalphJSmit\Laravel\SEO\Schema\Schema; +use RalphJSmit\Laravel\SEO\Support\SEOData; +/** + * @template TKey of array-key + * + * @extends Collection + */ class SchemaCollection extends Collection { protected array $dictionary = [ 'article' => ArticleSchema::class, - 'breadcrumbs' => BreadcrumbListSchema::class, - 'faqPage' => FaqPageSchema::class, + 'breadcrumb_list' => BreadcrumbListSchema::class, + 'faq_page' => FaqPageSchema::class, ]; public array $markup = []; public function addArticle(?Closure $builder = null): static { - $this->markup[$this->dictionary['article']][] = $builder ?: fn (Schema $schema): Schema => $schema; + $this->markup[$this->dictionary['article']][] = $builder ?: fn (ArticleSchema $schema): ArticleSchema => $schema; return $this; } public function addBreadcrumbs(?Closure $builder = null): static { - $this->markup[$this->dictionary['breadcrumbs']][] = $builder ?: fn (Schema $schema): Schema => $schema; + $this->markup[$this->dictionary['breadcrumb_list']][] = $builder ?: fn (BreadcrumbListSchema $schema): BreadcrumbListSchema => $schema; return $this; } public function addFaqPage(?Closure $builder = null): static { - $this->markup[$this->dictionary['faqPage']][] = $builder ?: fn (Schema $schema): Schema => $schema; + $this->markup[$this->dictionary['faq_page']][] = $builder ?: fn (FaqPageSchema $schema): FaqPageSchema => $schema; return $this; } diff --git a/src/Support/AlternateTag.php b/src/Support/AlternateTag.php index d32bf56..259c9f5 100644 --- a/src/Support/AlternateTag.php +++ b/src/Support/AlternateTag.php @@ -5,9 +5,11 @@ class AlternateTag extends LinkTag { public function __construct( - public string $hreflang, + string $hreflang, string $href, ) { parent::__construct('alternate', $href); + + $this->attributes['hreflang'] = $hreflang; } } diff --git a/src/Support/LinkTag.php b/src/Support/LinkTag.php index 2523a03..fd5a100 100644 --- a/src/Support/LinkTag.php +++ b/src/Support/LinkTag.php @@ -7,8 +7,10 @@ class LinkTag extends Tag public string $tag = 'link'; public function __construct( - public string $rel, - public string $href, + string $rel, + string $href, ) { + $this->attributes['rel'] = $rel; + $this->attributes['href'] = $href; } } diff --git a/src/Support/MetaContentTag.php b/src/Support/MetaContentTag.php index 86f2164..9fe3011 100644 --- a/src/Support/MetaContentTag.php +++ b/src/Support/MetaContentTag.php @@ -7,8 +7,10 @@ class MetaContentTag extends Tag public string $tag = 'meta'; public function __construct( - public string $property, - public string $content, + string $property, + string $content, ) { + $this->attributes['property'] = $property; + $this->attributes['content'] = $content; } } diff --git a/src/Support/MetaTag.php b/src/Support/MetaTag.php index e895727..35ae356 100644 --- a/src/Support/MetaTag.php +++ b/src/Support/MetaTag.php @@ -7,8 +7,10 @@ class MetaTag extends Tag public string $tag = 'meta'; public function __construct( - public string $name, - public string $content, + string $name, + string $content, ) { + $this->attributes['name'] = $name; + $this->attributes['content'] = $content; } } diff --git a/src/Support/OpenGraphTag.php b/src/Support/OpenGraphTag.php index 20acb61..1cd2a2e 100644 --- a/src/Support/OpenGraphTag.php +++ b/src/Support/OpenGraphTag.php @@ -9,9 +9,12 @@ class OpenGraphTag extends Tag public string $tag = 'meta'; public function __construct( - public string $property, - public string $content, + string $property, + string $content, ) { + $this->attributes['property'] = $property; + $this->attributes['content'] = $content; + $this->attributesPipeline[] = function (Collection $collection) { return $collection->mapWithKeys(function ($value, $key) { if ($key === 'property') { diff --git a/src/Support/SchemaTagCollection.php b/src/Support/SchemaTagCollection.php index df52c01..9dff9ba 100644 --- a/src/Support/SchemaTagCollection.php +++ b/src/Support/SchemaTagCollection.php @@ -4,22 +4,28 @@ use Illuminate\Contracts\Support\Renderable; use Illuminate\Support\Collection; -use RalphJSmit\Laravel\SEO\SchemaCollection; +use RalphJSmit\Laravel\SEO\Schema\CustomSchema; class SchemaTagCollection extends Collection implements Renderable { use RenderableCollection; - public static function initialize(SEOData $SEOData, ?SchemaCollection $schema = null): ?static + public static function initialize(?SEOData $SEOData = null): ?static { - $collection = new static(); + $schemas = $SEOData?->schema; - if (! $schema) { + if (! $schemas) { return null; } - foreach ($schema->markup as $markupClass => $markupBuilders) { - $collection = $collection->push(new $markupClass($SEOData, $markupBuilders)); + $collection = new static(); + + foreach ($schemas as $schema) { + $collection->push(new CustomSchema(value($schema, $SEOData))); + } + + foreach ($schemas->markup as $markupClass => $markupBuilders) { + $collection->push(new $markupClass($SEOData, $markupBuilders)); } return $collection; diff --git a/src/Support/SitemapTag.php b/src/Support/SitemapTag.php index c839532..c55944a 100644 --- a/src/Support/SitemapTag.php +++ b/src/Support/SitemapTag.php @@ -4,14 +4,15 @@ class SitemapTag extends LinkTag { - public string $rel = 'sitemap'; - - public string $type = 'application/xml'; - - public string $title = 'Sitemap'; + public array $attributes = [ + 'type' => 'application/xml', + 'rel' => 'sitemap', + 'title' => 'Sitemap', + ]; public function __construct( - public string $href + string $href ) { + $this->attributes['href'] = $href; } } diff --git a/src/Support/Tag.php b/src/Support/Tag.php index 704ea2e..43f257d 100644 --- a/src/Support/Tag.php +++ b/src/Support/Tag.php @@ -5,17 +5,27 @@ use Illuminate\Contracts\Support\Renderable; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; +use Illuminate\Support\HtmlString; abstract class Tag implements Renderable { - protected static array $reservedAttributes = [ - 'tag', - 'inner', - 'attributesPipeline', - ]; + const ATTRIBUTES_ORDER = ['rel', 'hreflang', 'title', 'name', 'href', 'property', 'description', 'content']; + /** + * The HTML tag + */ public string $tag; + /** + * The HTML attributes of the tag + */ + public array $attributes = []; + + /** + * The content of the tag + */ + public null | string | HtmlString $inner = null; + public array $attributesPipeline = []; public function render(): View @@ -23,19 +33,30 @@ public function render(): View return view('seo::tags.tag', [ 'tag' => $this->tag, 'attributes' => $this->collectAttributes(), - 'inner' => $this->inner ?? null, + 'inner' => $this->getInner(), ]); } public function collectAttributes(): Collection { - return collect($this->attributes ?? get_object_vars($this)) - ->except(static::$reservedAttributes) - ->pipe(function (Collection $attributes) { - $reservedAttributes = $attributes->only('property', 'name', 'rel'); - - return $reservedAttributes->merge($attributes->except('property', 'name', 'rel')->sortKeys()); + return collect($this->attributes) + ->map(fn ($attribute) => trim($attribute)) + ->sortKeysUsing(function ($a, $b) { + $indexA = array_search($a, static::ATTRIBUTES_ORDER); + $indexB = array_search($b, static::ATTRIBUTES_ORDER); + + return match (true) { + $indexB === $indexA => 0, // keep the order defined in $attributes if neither $a or $b are in ATTRIBUTES_ORDER + $indexA === false => 1, + $indexB === false => -1, + default => $indexA - $indexB + }; }) ->pipeThrough($this->attributesPipeline); } + + public function getInner(): null | string | HtmlString + { + return $this->inner; + } } diff --git a/src/Support/TwitterCardTag.php b/src/Support/TwitterCardTag.php index d3051cf..a8e8da6 100644 --- a/src/Support/TwitterCardTag.php +++ b/src/Support/TwitterCardTag.php @@ -9,9 +9,12 @@ class TwitterCardTag extends Tag public string $tag = 'meta'; public function __construct( - public string $name, - public string $content, + string $name, + string $content, ) { + $this->attributes['name'] = $name; + $this->attributes['content'] = $content; + $this->attributesPipeline[] = function (Collection $collection) { return $collection->mapWithKeys(function ($value, $key) { if ($key === 'name') { diff --git a/src/TagCollection.php b/src/TagCollection.php index 4b451a4..0c95f36 100644 --- a/src/TagCollection.php +++ b/src/TagCollection.php @@ -35,8 +35,8 @@ public static function initialize(?SEOData $SEOData = null): static FaviconTag::initialize($SEOData), OpenGraphTags::initialize($SEOData), TwitterCardTags::initialize($SEOData), - SchemaTagCollection::initialize($SEOData, $SEOData->schema), AlternateTags::initialize($SEOData), + SchemaTagCollection::initialize($SEOData), ])->reject(fn (?Renderable $item): bool => $item === null); foreach ($tags as $tag) { diff --git a/src/Tags/DescriptionTag.php b/src/Tags/DescriptionTag.php index 53b6e62..caed7f3 100644 --- a/src/Tags/DescriptionTag.php +++ b/src/Tags/DescriptionTag.php @@ -17,7 +17,7 @@ public static function initialize(?SEOData $SEOData): ?MetaTag return new MetaTag( name: 'description', - content: trim($description) + content: $description ); } } diff --git a/src/Tags/TitleTag.php b/src/Tags/TitleTag.php index 13bff8e..64793d5 100644 --- a/src/Tags/TitleTag.php +++ b/src/Tags/TitleTag.php @@ -10,8 +10,9 @@ class TitleTag extends Tag public string $tag = 'title'; public function __construct( - public string $inner, + string $inner, ) { + $this->inner = trim($inner); } public static function initialize(?SEOData $SEOData): ?Tag @@ -23,7 +24,7 @@ public static function initialize(?SEOData $SEOData): ?Tag } return new static( - inner: trim($title), + inner: $title, ); } } diff --git a/tests/Feature/JSON-LD/ArticleTest.php b/tests/Feature/JSON-LD/ArticleTest.php index 9b2d9f8..53352d9 100644 --- a/tests/Feature/JSON-LD/ArticleTest.php +++ b/tests/Feature/JSON-LD/ArticleTest.php @@ -39,28 +39,28 @@ ->assertSee('"application/ld+json"', false) ->assertSee( '', + 'image' => secure_url('images/twitter-1743x1743.jpg'), + ]) . '', false ); }); diff --git a/tests/Feature/JSON-LD/BreadcrumbListTest.php b/tests/Feature/JSON-LD/BreadcrumbListTest.php index 4f7c211..fed2759 100644 --- a/tests/Feature/JSON-LD/BreadcrumbListTest.php +++ b/tests/Feature/JSON-LD/BreadcrumbListTest.php @@ -37,36 +37,36 @@ ->assertSee('"application/ld+json"', false) ->assertSee( '', + ]) . '', false ); }); diff --git a/tests/Feature/JSON-LD/FaqPageTest.php b/tests/Feature/JSON-LD/FaqPageTest.php index 951fc14..8798d3b 100644 --- a/tests/Feature/JSON-LD/FaqPageTest.php +++ b/tests/Feature/JSON-LD/FaqPageTest.php @@ -32,28 +32,28 @@ ->assertSee('"application/ld+json"', false) ->assertSee( '', + ], + ]) . '', false ); }); diff --git a/tests/Feature/JSON-LD/SchemaCollectionTest.php b/tests/Feature/JSON-LD/SchemaCollectionTest.php new file mode 100644 index 0000000..e1d0aee --- /dev/null +++ b/tests/Feature/JSON-LD/SchemaCollectionTest.php @@ -0,0 +1,342 @@ + 'https://schema.org', + '@type' => 'FAQPage', + 'mainEntity' => [ + '@type' => 'Question', + 'name' => 'Can this package add FaqPage to the schema?', + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => 'Yes!', + ], + ], + [ + '@type' => 'Question', + 'name' => 'Does it support multiple questions?', + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => 'Of course.', + ], + ], + ]; + + $page::$overrides = [ + 'schema' => SchemaCollection::make()->add($faqPageSchema), + ]; + + get(route('seo.test-page', ['page' => $page])) + ->assertSee('"application/ld+json"', false) + ->assertSee('', false); +}); + +it('can correctly render a custom JSON-LD Schemas markup from a function', function () { + $page = Page::create([]); + + $now = now(); + $yesterday = now()->yesterday(); + + $page::$overrides = [ + 'title' => 'Test title', + 'published_time' => $yesterday, + 'modified_time' => $now, + 'url' => 'https://example.com', + 'author' => 'Ralph J. Smit', + 'schema' => SchemaCollection::make() + ->add(fn (SEOData $SEOData) => [ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'mainEntityOfPage' => [ + '@type' => 'WebPage', + '@id' => $SEOData->url, + ], + 'datePublished' => $SEOData->published_time->toIso8601String(), + 'dateModified' => $SEOData->modified_time->toIso8601String(), + 'headline' => $SEOData->title, + 'author' => [ + [ + '@type' => 'Person', + 'name' => $SEOData->author, + ], + ], + ]), + ]; + + get(route('seo.test-page', ['page' => $page])) + ->assertSee('"application/ld+json"', false) + ->assertSee( + '', + false + ); +}); + +it('can correctly render the JSON-LD Schema markup: Article', function () { + $created_at = now()->subDays(2); + $updated_at = now(); + + $page = Page::create([ + 'created_at' => $created_at, + 'updated_at' => $updated_at, + ]); + + $page::$overrides = [ + 'title' => 'Test title', + 'image' => 'images/twitter-1743x1743.jpg', + 'author' => 'Ralph J. Smit', + 'schema' => SchemaCollection::make() + ->addArticle(function (ArticleSchema $article, SEOData $SEOData) { + return $article + ->addAuthor('Second author') + ->markup(function (Collection $markup) { + return $markup->mergeRecursive([ + 'alternativeHeadline' => 'My alternative headline', + ]); + }); + }), + ]; + + get(route('seo.test-page', ['page' => $page])) + ->assertSee('"application/ld+json"', false) + ->assertSee( + '', + false + ); +}); + +it('can correctly render the JSON-LD Schema markup: BreadcrumbList', function () { + config()->set('seo.title.suffix', ' | Laravel SEO'); + + $page = Page::create([]); + + $page::$overrides = [ + 'title' => 'Test article', + 'enableTitleSuffix' => true, + 'url' => 'https://example.com/test/article', + 'schema' => SchemaCollection::make() + ->addBreadcrumbs(function (BreadcrumbListSchema $breadcrumbList, SEOData $SEOData) { + return $breadcrumbList + ->prependBreadcrumbs([ + 'Homepage' => 'https://example.com', + 'Category' => 'https://example.com/test', + ]) + ->appendBreadcrumbs([ + 'Subarticle' => 'https://example.com/test/article/2', + ]); + }), + ]; + + get(route('seo.test-page', ['page' => $page])) + ->assertSee('"application/ld+json"', false) + ->assertSee( + '', + false + ); +}); + +it('can correctly render the JSON-LD Schema markup: FaqPage', function () { + config()->set('seo.title.suffix', ' | Laravel SEO'); + + $page = Page::create([]); + + $page::$overrides = [ + 'title' => 'Test article', + 'enableTitleSuffix' => true, + 'url' => 'https://example.com/test/article', + 'schema' => SchemaCollection::make() + ->addFaqPage(function (FaqPageSchema $faqPage, SEOData $SEOData) { + return $faqPage + ->addQuestion(name: 'Can this package add FaqPage to the schema?', acceptedAnswer: 'Yes!') + ->addQuestion(name: 'Does it support multiple questions?', acceptedAnswer: 'Of course.'); + }), + ]; + + get(route('seo.test-page', ['page' => $page])) + ->assertSee('"application/ld+json"', false) + ->assertSee( + '', + false + ); +}); + +it('can correctly render multiple custom JSON-LD Schemas markup', function () { + $page = Page::create([]); + + $faqPageSchema = [ + '@context' => 'https://schema.org', + '@type' => 'FAQPage', + 'mainEntity' => [ + '@type' => 'Question', + 'name' => 'Can this package add FaqPage to the schema?', + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => 'Yes!', + ], + ], + [ + '@type' => 'Question', + 'name' => 'Does it support multiple questions?', + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => 'Of course.', + ], + ], + ]; + + $now = now(); + $yesterday = now()->yesterday(); + + $page::$overrides = [ + 'title' => 'Test title', + 'published_time' => $yesterday, + 'modified_time' => $now, + 'url' => 'https://example.com', + 'author' => 'Ralph J. Smit', + 'schema' => SchemaCollection::make() + ->add($faqPageSchema) + ->add(fn (SEOData $SEOData) => [ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'mainEntityOfPage' => [ + '@type' => 'WebPage', + '@id' => $SEOData->url, + ], + 'datePublished' => $SEOData->published_time->toIso8601String(), + 'dateModified' => $SEOData->modified_time->toIso8601String(), + 'headline' => $SEOData->title, + 'author' => [ + [ + '@type' => 'Person', + 'name' => $SEOData->author, + ], + ], + ]), + ]; + + get(route('seo.test-page', ['page' => $page])) + ->assertSee('"application/ld+json"', false) + ->assertSee('', false) + ->assertSee( + '', + false + ); +}); diff --git a/tests/Feature/Tags/AlternateTagTest.php b/tests/Feature/Tags/AlternateTagTest.php index 8906847..738d757 100644 --- a/tests/Feature/Tags/AlternateTagTest.php +++ b/tests/Feature/Tags/AlternateTagTest.php @@ -27,6 +27,6 @@ ]; get(route('seo.test-page', ['page' => $page])) - ->assertSee('', false) - ->assertSee('', false); + ->assertSee('', false) + ->assertSee('', false); }); diff --git a/tests/Feature/Tags/SitemapTagTest.php b/tests/Feature/Tags/SitemapTagTest.php index 369ea1e..9fae4b6 100644 --- a/tests/Feature/Tags/SitemapTagTest.php +++ b/tests/Feature/Tags/SitemapTagTest.php @@ -6,5 +6,5 @@ config()->set('seo.sitemap', '/storage/sitemap.xml'); get($url = route('seo.test-plain')) - ->assertSee('', false); + ->assertSee('', false); }); diff --git a/tests/Unit/Schema/ArticleSchemaTest.php b/tests/Unit/Schema/ArticleSchemaTest.php index 67cbb6e..f54198d 100644 --- a/tests/Unit/Schema/ArticleSchemaTest.php +++ b/tests/Unit/Schema/ArticleSchemaTest.php @@ -18,29 +18,29 @@ }); it('can construct Schema Markup: Article', function () { - $articleSchema = new ArticleSchema($this->SEOData, []); + $articleSchema = new ArticleSchema($this->SEOData); expect((string) $articleSchema->render()) ->toBe( '' + $string = json_encode([ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'mainEntityOfPage' => [ + '@type' => 'WebPage', + '@id' => 'https://example.com/test', + ], + 'datePublished' => now()->subDays(3)->toIso8601String(), + 'dateModified' => now()->toIso8601String(), + 'headline' => 'Test', + 'author' => [ + '@type' => 'Person', + 'name' => 'Ralph J. Smit', + ], + 'description' => 'Description', + 'image' => 'https://example.com/image.jpg', + 'articleBody' => '

Test

', + ]) . '' ); }); @@ -55,30 +55,30 @@ expect((string) $articleSchema->render())->toBe( '' + 'description' => 'Description', + 'image' => 'https://example.com/image.jpg', + 'articleBody' => '

Test

', + 'alternativeHeadline' => 'My alternative headline', + ]) . '' ); }); diff --git a/tests/Unit/Schema/CustomSchemaTest.php b/tests/Unit/Schema/CustomSchemaTest.php new file mode 100644 index 0000000..26626f1 --- /dev/null +++ b/tests/Unit/Schema/CustomSchemaTest.php @@ -0,0 +1,50 @@ + 'https://schema.org', + '@type' => 'FAQPage', + 'mainEntity' => [ + '@type' => 'Question', + 'name' => 'Can this package add FaqPage to the schema?', + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => 'Yes!', + ], + ], + [ + '@type' => 'Question', + 'name' => 'Does it support multiple questions?', + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => 'Of course.', + ], + ], + ]); + + expect((string) $schema->render()) + ->toBe( + '' + ); +}); diff --git a/tests/Unit/Support/TagTest.php b/tests/Unit/Support/TagTest.php new file mode 100644 index 0000000..cde0dcc --- /dev/null +++ b/tests/Unit/Support/TagTest.php @@ -0,0 +1,26 @@ +attributes = [ + 'hreflang' => 'hreflang', + 'description' => 'description', + 'title' => 'title', + 'content' => 'content', + 'name' => 'name', + 'href' => 'href', + 'foo' => 'foo', + 'property' => 'property', + 'bar' => 'bar', + 'rel' => 'rel', + ]; + + expect((string) $tag->render()) + ->toBe(''); +});