diff --git a/README.md b/README.md index 3069262..de5a996 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,10 @@ class Homepage extends Controller 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 in JSON with the correct tags on the right place by the package. +- Use one of the 2 pre-defined templates (`Article` and `BreadcrumbList`). + ### Adding your first schema Let's add the FAQPage schema markup to our website as an example: @@ -343,18 +347,21 @@ public function getDynamicSEOData(): SEOData { return new SEOData( // ... - schema: SchemaCollection::make()->add(fn(SEOData $SEOData) => [ - '@context' => 'https://schema.org', - '@type' => 'FAQPage', - 'mainEntity' => [ - '@type' => 'Question', - 'name' => 'Your question goes here', - 'acceptedAnswer' => [ - '@type' => 'Answer', - 'text' => 'Your answer goes here', + schema: SchemaCollection::make() + ->add(fn (SEOData $SEOData) => [ + // You could use the `$SEOData` to dynamically + // use 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', + ], ], - ], - ]), + ]), ); } ``` @@ -362,22 +369,17 @@ public function getDynamicSEOData(): SEOData > [!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. -> [!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). - ### Pre-configured Schema: Article and BreadcrumbList -To help you get started with structured data, we added 2 preconfigured schema: +To help you get started with structured data, we added 3 preconfigured schema that you can modify using fluent methods. The following types are available: 1. `Article` 2. `BreadcrumbList` +3. `FAQPage` ### Article schema markup -You can add a pre-configured article with the `withArticle` method, this will generate a fully filled Article JSON schema using the values from your `SEOData` instance. - -> [!NOTE] -> Check the Google documentation about [Article](https://developers.google.com/search/docs/appearance/structured-data/article) +To enable structured data, you need to use the schema property of the SEOData class. To automatically generate `Article` schema markup, use the `->addArticle()` method: ```php @@ -387,14 +389,16 @@ public function getDynamicSEOData(): SEOData { return new SEOData( // ... - schema: SchemaCollection::make()->withArticle(), + schema: SchemaCollection::make() + ->addArticle(), ); } ``` -You can also pass a closure to the `->withArticle()` method to customize the individual schema markup. +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 +use RalphJSmit\Laravel\SEO\Schema\ArticleSchema; use RalphJSmit\Laravel\SEO\SchemaCollection; use RalphJSmit\Laravel\SEO\Support\SEOData; use Illuminate\Support\Collection; @@ -404,21 +408,25 @@ public function getDynamicSEOData(): SEOData return new SEOData( // ... title: "A boring title" - schema: SchemaCollection::make()->withArticle(function(SEOData $SEOData, Collection $article){ - return $article->mergeRecursive([ - 'alternativeHeadline' => "Not {$SEOData->title}", // will be "Not A boring title" - 'author' => [ - [ - '@type' => 'Person', - 'name' => $this->moderator, - ] - ] - ]); - }), + schema: SchemaCollection::make() + ->addArticle(function (ArticleSchema $article, SEOData $SEOData): ArticleSchema { + return $article + ->addAuthor($this->moderator) + ->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"` :) + ->mergeRecursive([ + //... + ]); + }); + }), ); } ``` +> [!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 `->withBreadcrumbList()` function on the `SchemaCollection`. @@ -430,33 +438,19 @@ use RalphJSmit\Laravel\SEO\SchemaCollection; use RalphJSmit\Laravel\SEO\Support\SEOData; use Illuminate\Support\Collection; -SchemaCollection::make() - ->withBreadcrumbList(function (SEOData $SEOData, Collection $breadcrumbs) { - $items = $breadcrumb->get('itemListElement', []); - - $breadcrumb->put( - 'itemListElement', - [ - [ - '@type' => 'ListItem', - 'name' => 'Homepage', - 'item' => 'https://example.com', - ], - [ - '@type' => 'ListItem', - 'name' => 'Category', - 'item' => 'https://example.com/test', - ], - ...$items, - [ - '@type' => 'ListItem', - 'name' => 'Subarticle', - 'item' => 'https://example.com/test/article/2', - ] - ], - ); - - return $breadcrumb; +SchemaCollection::initialize() + ->addBreadcrumbs(function (BreadcrumbListSchema $breadcrumbs, SEOData $SEOData): BreadcrumbListSchema { + return $breadcrumbs + ->prependBreadcrumbs([ + 'Homepage' => 'https://example.com', + 'Category' => 'https://example.com/test', + ]) + ->appendBreadcrumbs([ + 'Subarticle' => 'https://example.com/test/article/2', + ]) + ->markup(function (Collection $markup): Collection { + // ... + }); }); ``` @@ -467,6 +461,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: + +```php +use RalphJSmit\Laravel\SEO\Schema\FaqPageSchema; +use RalphJSmit\Laravel\SEO\SchemaCollection;use RalphJSmit\Laravel\SEO\Support\SEOData; + +SchemaCollection::initialize() + ->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. diff --git a/src/Schema/ArticleSchema.php b/src/Schema/ArticleSchema.php index 4bf0b05..b55ded5 100644 --- a/src/Schema/ArticleSchema.php +++ b/src/Schema/ArticleSchema.php @@ -3,14 +3,11 @@ namespace RalphJSmit\Laravel\SEO\Schema; use Carbon\CarbonInterface; +use Closure; use Illuminate\Support\Collection; -use Illuminate\Support\HtmlString; use RalphJSmit\Laravel\SEO\Support\SEOData; -/** - * @deprecated Use CustomSchema paradigm - */ -class ArticleSchema extends Schema +class ArticleSchema extends CustomPreDefinedSchema { public array $authors = []; @@ -32,7 +29,7 @@ class ArticleSchema extends Schema public function addAuthor(string $authorName): static { - if (empty($this->authors)) { + if ( ! $this->authors) { $this->authors = [ '@type' => 'Person', 'name' => $authorName, @@ -52,7 +49,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; @@ -66,12 +63,12 @@ public function initializeMarkup(SEOData $SEOData, array $markupBuilders): void ]; foreach ($properties as $markupProperty => $SEODataProperty) { - if ($SEOData->{$SEODataProperty}) { + if ( $SEOData->{$SEODataProperty} ) { $this->{$markupProperty} = $SEOData->{$SEODataProperty}; } } - if ($SEOData->author) { + if ( $SEOData->author ) { $this->authors = [ '@type' => 'Person', 'name' => $SEOData->author, @@ -79,9 +76,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' => [ @@ -98,7 +95,5 @@ public function generateInner(): HtmlString ->when($this->articleBody, fn (Collection $collection): Collection => $collection->put('articleBody', $this->articleBody)) ->pipeThrough($this->markupTransformers) ->toJson(); - - return new HtmlString($inner); } } diff --git a/src/Schema/BreadcrumbListSchema.php b/src/Schema/BreadcrumbListSchema.php index 3251036..9d118a1 100644 --- a/src/Schema/BreadcrumbListSchema.php +++ b/src/Schema/BreadcrumbListSchema.php @@ -2,14 +2,12 @@ namespace RalphJSmit\Laravel\SEO\Schema; +use Closure; use Illuminate\Support\Collection; use Illuminate\Support\HtmlString; use RalphJSmit\Laravel\SEO\Support\SEOData; -/** - * @deprecated Use CustomSchema paradigm - */ -class BreadcrumbListSchema extends Schema +class BreadcrumbListSchema extends CustomPreDefinedSchema { public Collection $breadcrumbs; @@ -24,16 +22,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 @@ -48,8 +46,6 @@ public function generateInner(): HtmlString ]) ->pipeThrough($this->markupTransformers) ->toJson(); - - return new HtmlString($inner); } public function prependBreadcrumbs(array $breadcrumbs): static diff --git a/src/Schema/CustomPreDefinedSchema.php b/src/Schema/CustomPreDefinedSchema.php new file mode 100644 index 0000000..4e898ca --- /dev/null +++ b/src/Schema/CustomPreDefinedSchema.php @@ -0,0 +1,36 @@ +initializeMarkup($SEOData); + + // `$markupBuilders` are closures that modify this 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 new file mode 100644 index 0000000..df68f62 --- /dev/null +++ b/src/Schema/FaqPageSchema.php @@ -0,0 +1,47 @@ +questions[] = [ + '@type' => 'Question', + 'name' => $name, + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => $acceptedAnswer, + ], + ]; + + return $this; + } + + public function initializeMarkup(SEOData $SEOData): void + { + $this->questions = new Collection(); + } + + public function generateInner(): Collection + { + return collect([ + '@context' => 'https://schema.org', + '@type' => $this->type, + 'mainEntity' => $this->questions, + ]) + ->pipeThrough($this->markupTransformers) + ->toJson(); + } +} \ No newline at end of file diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php deleted file mode 100644 index 1fd1bc1..0000000 --- a/src/Schema/Schema.php +++ /dev/null @@ -1,48 +0,0 @@ - 'application/ld+json', - ]; - - public Collection $markup; - - public array $markupTransformers = []; - - 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 ec48479..e647a23 100644 --- a/src/SchemaCollection.php +++ b/src/SchemaCollection.php @@ -8,7 +8,6 @@ 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; /** @@ -20,95 +19,31 @@ 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 = []; - /** - * @deprecated use withArticle instead - */ 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; } - /** - * @param null|(Closure(SEOData $SEOData, Collection $article): Collection) $builder - */ - public function withArticle(null | array | Closure $builder = null): static + public function addFaqPage(?Closure $builder = null): static { - return $this->add(function (SEOData $SEOData) use ($builder) { - $schema = collect([ - '@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, - 'description' => $SEOData->description, - 'image' => $SEOData->image, - ])->when($SEOData->author, fn (Collection $schema) => $schema->put('author', [[ - '@type' => 'Person', - 'name' => $SEOData->author, - ]])); + $this->markup[$this->dictionary['faq_page']][] = $builder ?: fn (FaqPageSchema $schema): FaqPageSchema => $schema; - if ($builder) { - $schema = $builder($SEOData, $schema); - } - - return $schema->filter(); - }); - } - - /** - * @param null|(Closure(SEOData $SEOData, Collection $breadcrumbList): Collection) $builder - */ - public function withBreadcrumbList(null | array | Closure $builder = null): static - { - return $this->add(function (SEOData $SEOData) use ($builder) { - $schema = collect([ - '@context' => 'https://schema.org', - '@type' => 'BreadcrumbList', - 'itemListElement' => collect([ - [ - '@type' => 'ListItem', - 'name' => $SEOData->title, - 'item' => $SEOData->url, - 'position' => 1, - ], - ]), - ]); - - if ($builder) { - $schema = $builder($SEOData, $schema); - - /** - * Make sure position are in the right order after builder manipulation - */ - $schema->put( - 'itemListElement', - collect($schema->get('itemListElement', [])) - ->values() - ->map(fn (array $item, int $key) => [...$item, 'position' => $key + 1]) - ); - } - - return $schema->filter(); - }); + return $this; } public static function initialize(): static diff --git a/src/Support/Tag.php b/src/Support/Tag.php index 838648a..4ebafd1 100644 --- a/src/Support/Tag.php +++ b/src/Support/Tag.php @@ -27,7 +27,7 @@ abstract class Tag implements Renderable /** * The content of the tag */ - public null | string | HtmlString $inner = null; + public null|string|HtmlString $inner = null; public array $attributesPipeline = []; @@ -36,7 +36,7 @@ public function render(): View return view('seo::tags.tag', [ 'tag' => $this->tag, 'attributes' => $this->collectAttributes(), - 'inner' => $this->inner, + 'inner' => $this->getInner(), ]); } @@ -48,7 +48,7 @@ public function collectAttributes(): Collection $indexA = array_search($a, static::ATTRIBUTES_ORDER); $indexB = array_search($b, static::ATTRIBUTES_ORDER); - return match (true) { + 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, @@ -57,4 +57,9 @@ public function collectAttributes(): Collection }) ->pipeThrough($this->attributesPipeline); } + + public function getInner(): null|string|HtmlString + { + return $this->inner; + } }