Skip to content

Commit a068b7f

Browse files
authored
Add JSON-LD structured data design spec (#14)
* Add JSON-LD structured data design spec * Remove design spec from version control * Add JSON-LD storage property and render helper * Add tests for setBreadcrumbs/getBreadcrumbs * Implement setBreadcrumbs and getBreadcrumbs methods - Add setBreadcrumbs() method to set breadcrumb JSON-LD structured data - Add getBreadcrumbs() method to retrieve rendered JSON-LD output - Update renderJsonLd() to output compact JSON without pretty-print - Fix test setUp with fallbacks() for generic route URL reversal - Add fullBaseUrl configuration for full URL generation in tests - Add HTTP_HOST to test request for proper host-aware URL building - All 5 breadcrumb tests pass, PHPStan analysis clean * Fix testMetaCanonical test assertions The test was comparing relative URLs but assertions were using fullBase. Updated assertions to not use fullBase to match getCanonical() behavior which doesn't use the full parameter by default. * Fix renderJsonLd to include debug pretty-print and update tests * Add tests for setArticle/getArticle * Implement setArticle and getArticle * Add tests for setOrganization/getOrganization * Implement setOrganization and getOrganization * Integrate JSON-LD output into out() method * Add test for JSON-LD debug pretty-print * Document JSON-LD structured data features * Fix code style in tests
1 parent a460509 commit a068b7f

4 files changed

Lines changed: 559 additions & 4 deletions

File tree

docs/MetaHelper.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,91 @@ You can also manually output each group of meta tags, e.g. all keywords and desc
7171
echo $this->Meta->getKeywords();
7272
echo $this->Meta->getDescription();
7373
```
74+
75+
## JSON-LD Structured Data
76+
77+
The helper supports generating JSON-LD structured data for improved SEO.
78+
79+
### Breadcrumbs
80+
81+
```php
82+
$this->Meta->setBreadcrumbs([
83+
['name' => 'Home', 'url' => '/'],
84+
['name' => 'Blog', 'url' => '/blog'],
85+
['name' => 'My Post'], // Last item typically has no URL
86+
]);
87+
```
88+
89+
URLs can be strings or CakePHP URL arrays:
90+
91+
```php
92+
$this->Meta->setBreadcrumbs([
93+
['name' => 'Home', 'url' => ['controller' => 'Pages', 'action' => 'home']],
94+
['name' => 'Products', 'url' => ['controller' => 'Products', 'action' => 'index']],
95+
['name' => 'Widget'],
96+
]);
97+
```
98+
99+
### Article
100+
101+
```php
102+
$this->Meta->setArticle([
103+
'headline' => 'How to Use JSON-LD', // Required
104+
'author' => 'John Doe', // Optional
105+
'datePublished' => '2026-03-19', // Optional
106+
'dateModified' => '2026-03-19', // Optional
107+
'image' => 'https://example.com/image.jpg', // Optional
108+
'description' => 'A guide to structured data', // Optional
109+
]);
110+
```
111+
112+
### Organization
113+
114+
```php
115+
$this->Meta->setOrganization([
116+
'name' => 'Acme Inc', // Required
117+
'url' => 'https://acme.com', // Optional
118+
'logo' => 'https://acme.com/logo.png', // Optional
119+
'sameAs' => [ // Optional
120+
'https://twitter.com/acme',
121+
'https://facebook.com/acme',
122+
],
123+
]);
124+
```
125+
126+
Organization data can be configured globally in `config/app.php`:
127+
128+
```php
129+
'Meta' => [
130+
'organization' => [
131+
'name' => 'Acme Inc',
132+
'url' => 'https://acme.com',
133+
'logo' => 'https://acme.com/logo.png',
134+
],
135+
],
136+
```
137+
138+
Then override per-page as needed:
139+
140+
```php
141+
$this->Meta->setOrganization(['name' => 'Acme Blog Division']);
142+
// Inherits url and logo from global config
143+
```
144+
145+
### Output
146+
147+
JSON-LD is automatically included when calling `out()`:
148+
149+
```php
150+
echo $this->Meta->out();
151+
```
152+
153+
Or retrieve individually:
154+
155+
```php
156+
echo $this->Meta->getBreadcrumbs();
157+
echo $this->Meta->getArticle();
158+
echo $this->Meta->getOrganization();
159+
```
160+
161+
In debug mode, JSON-LD is pretty-printed for readability.

src/View/Helper/MetaHelper.php

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ class MetaHelper extends Helper {
4949
'custom' => [],
5050
];
5151

52+
/**
53+
* JSON-LD structured data storage.
54+
*
55+
* @var array<string, array<string, mixed>|null>
56+
*/
57+
protected array $_jsonLd = [
58+
'breadcrumbs' => null,
59+
'article' => null,
60+
'organization' => null,
61+
];
62+
5263
/**
5364
* Class Constructor
5465
*
@@ -538,6 +549,173 @@ public function setHttpEquiv(string $type, string|false $value): void {
538549
$this->meta['http-equiv'][$type] = $value;
539550
}
540551

552+
/**
553+
* Set breadcrumbs JSON-LD structured data.
554+
*
555+
* @param array<int, array<string, string|array>> $items Breadcrumb items with 'name' key required.
556+
* @throws \InvalidArgumentException If items are empty or missing required fields.
557+
* @return void
558+
*/
559+
public function setBreadcrumbs(array $items): void {
560+
if ($items === []) {
561+
throw new InvalidArgumentException('Breadcrumbs require at least one item.');
562+
}
563+
564+
$listItems = [];
565+
foreach ($items as $i => $item) {
566+
if (!isset($item['name']) || !is_string($item['name'])) {
567+
throw new InvalidArgumentException("Breadcrumb item {$i} requires a 'name' string.");
568+
}
569+
570+
$listItem = [
571+
'@type' => 'ListItem',
572+
'position' => $i + 1,
573+
'name' => $item['name'],
574+
];
575+
576+
if (isset($item['url'])) {
577+
$url = is_array($item['url'])
578+
? $this->Url->build($item['url'], ['fullBase' => true])
579+
: $item['url'];
580+
$listItem['item'] = $url;
581+
}
582+
583+
$listItems[] = $listItem;
584+
}
585+
586+
$this->_jsonLd['breadcrumbs'] = [
587+
'@type' => 'BreadcrumbList',
588+
'itemListElement' => $listItems,
589+
];
590+
}
591+
592+
/**
593+
* Get breadcrumbs JSON-LD output.
594+
*
595+
* @return string|null
596+
*/
597+
public function getBreadcrumbs(): ?string {
598+
if ($this->_jsonLd['breadcrumbs'] === null) {
599+
return null;
600+
}
601+
602+
return $this->renderJsonLd($this->_jsonLd['breadcrumbs']);
603+
}
604+
605+
/**
606+
* Set article JSON-LD structured data.
607+
*
608+
* @param array<string, mixed> $data Article data with required 'headline' key.
609+
* @throws \InvalidArgumentException If headline is missing.
610+
* @return void
611+
*/
612+
public function setArticle(array $data): void {
613+
if (!isset($data['headline']) || !is_string($data['headline'])) {
614+
throw new InvalidArgumentException("Article requires a 'headline' string.");
615+
}
616+
617+
$article = [
618+
'@type' => 'Article',
619+
'headline' => $data['headline'],
620+
];
621+
622+
if (isset($data['author'])) {
623+
if (is_string($data['author'])) {
624+
$article['author'] = [
625+
'@type' => 'Person',
626+
'name' => $data['author'],
627+
];
628+
} else {
629+
$article['author'] = $data['author'];
630+
}
631+
}
632+
633+
if (isset($data['datePublished'])) {
634+
$article['datePublished'] = $data['datePublished'];
635+
}
636+
637+
if (isset($data['dateModified'])) {
638+
$article['dateModified'] = $data['dateModified'];
639+
}
640+
641+
if (isset($data['image'])) {
642+
$article['image'] = $data['image'];
643+
}
644+
645+
if (isset($data['description'])) {
646+
$article['description'] = $data['description'];
647+
}
648+
649+
$this->_jsonLd['article'] = $article;
650+
}
651+
652+
/**
653+
* Get article JSON-LD output.
654+
*
655+
* @return string|null
656+
*/
657+
public function getArticle(): ?string {
658+
if ($this->_jsonLd['article'] === null) {
659+
return null;
660+
}
661+
662+
return $this->renderJsonLd($this->_jsonLd['article']);
663+
}
664+
665+
/**
666+
* Set organization JSON-LD structured data.
667+
*
668+
* Merges with global config from Configure::read('Meta.organization').
669+
*
670+
* @param array<string, mixed> $data Organization data.
671+
* @throws \InvalidArgumentException If name is missing after merge.
672+
* @return void
673+
*/
674+
public function setOrganization(array $data): void {
675+
$globalConfig = (array)Configure::read('Meta.organization');
676+
$data = array_merge($globalConfig, $data);
677+
678+
if (!isset($data['name']) || !is_string($data['name'])) {
679+
throw new InvalidArgumentException("Organization requires a 'name' string.");
680+
}
681+
682+
$organization = [
683+
'@type' => 'Organization',
684+
'name' => $data['name'],
685+
];
686+
687+
if (isset($data['url'])) {
688+
$organization['url'] = $data['url'];
689+
}
690+
691+
if (isset($data['logo'])) {
692+
$organization['logo'] = $data['logo'];
693+
}
694+
695+
if (isset($data['contactPoint'])) {
696+
$organization['contactPoint'] = $data['contactPoint'];
697+
}
698+
699+
if (isset($data['sameAs'])) {
700+
$organization['sameAs'] = $data['sameAs'];
701+
}
702+
703+
$this->_jsonLd['organization'] = $organization;
704+
}
705+
706+
/**
707+
* Get organization JSON-LD output.
708+
*
709+
* @return string|null
710+
*/
711+
public function getOrganization(): ?string {
712+
if ($this->_jsonLd['organization'] === null) {
713+
return null;
714+
}
715+
716+
return $this->renderJsonLd($this->_jsonLd['organization']);
717+
}
718+
541719
/**
542720
* @param string|null $type
543721
* @return string
@@ -579,6 +757,23 @@ protected function httpEquiv(string $type, string|false $value): string {
579757
return (string)$this->Html->meta($array);
580758
}
581759

760+
/**
761+
* Render JSON-LD script tag.
762+
*
763+
* @param array<string, mixed> $data Schema data without @context.
764+
* @return string
765+
*/
766+
protected function renderJsonLd(array $data): string {
767+
$data = ['@context' => 'https://schema.org'] + $data;
768+
769+
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
770+
if (Configure::read('debug')) {
771+
$flags |= JSON_PRETTY_PRINT;
772+
}
773+
774+
return '<script type="application/ld+json">' . json_encode($data, $flags) . '</script>';
775+
}
776+
582777
/**
583778
* Outputs a meta header or series of meta headers
584779
*
@@ -674,6 +869,21 @@ public function out(?string $header = null, array $options = []): string {
674869
$results[] = $out;
675870
}
676871

872+
// Append JSON-LD structured data
873+
$jsonLdOutput = [];
874+
if ($this->_jsonLd['breadcrumbs'] !== null) {
875+
$jsonLdOutput[] = $this->getBreadcrumbs();
876+
}
877+
if ($this->_jsonLd['article'] !== null) {
878+
$jsonLdOutput[] = $this->getArticle();
879+
}
880+
if ($this->_jsonLd['organization'] !== null) {
881+
$jsonLdOutput[] = $this->getOrganization();
882+
}
883+
if ($jsonLdOutput !== []) {
884+
$results[] = implode($options['implode'], $jsonLdOutput);
885+
}
886+
677887
return implode($options['implode'], $results);
678888
}
679889

0 commit comments

Comments
 (0)