diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml
index 9521fa5..e58169a 100644
--- a/.github/workflows/smoke.yml
+++ b/.github/workflows/smoke.yml
@@ -163,15 +163,21 @@ jobs:
echo "FAIL TigerSEO head tags missing"; fail=1
fi
- # JSON-LD site-identity graph — the page must carry a ld+json block with the Organization node
- # (the schema Google reads for brand sitelinks). Also assert it stayed valid JSON.
+ # JSON-LD — the page must carry the Organization node (brand/sitelinks) AND a BreadcrumbList
+ # (the seeded single-segment page yields Home →
). Every ld+json block must parse.
if grep -q 'application/ld+json' page.html && grep -q '"Organization"' page.html; then
echo "PASS TigerSEO JSON-LD (Organization graph) rendered"
- php -r '$h=file_get_contents("page.html"); if(preg_match("##s",$h,$m)){exit(json_decode($m[1])!==null?0:1);} exit(1);' \
- && echo "PASS JSON-LD is valid JSON" || { echo "FAIL JSON-LD did not parse"; fail=1; }
else
- echo "FAIL TigerSEO JSON-LD missing"; fail=1
+ echo "FAIL TigerSEO JSON-LD (Organization) missing"; fail=1
fi
+ if grep -q '"BreadcrumbList"' page.html; then
+ echo "PASS TigerSEO JSON-LD BreadcrumbList rendered"
+ else
+ echo "FAIL TigerSEO JSON-LD BreadcrumbList missing"; fail=1
+ fi
+ # validate EVERY ld+json block, not just the first
+ php -r '$h=file_get_contents("page.html"); preg_match_all("##s",$h,$m); if(!$m[1]){exit(1);} foreach($m[1] as $b){ if(json_decode($b)===null){exit(1);} } exit(0);' \
+ && echo "PASS all JSON-LD blocks are valid JSON" || { echo "FAIL a JSON-LD block did not parse"; fail=1; }
# sitemap.xml + robots.txt are ROUTES (not files). The sitemap must list the seeded page (the
# pages provider), and robots must point crawlers at the sitemap.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 639d737..25ebe02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows
## [Unreleased]
+### Added
+- **TigerSEO — Article + BreadcrumbList JSON-LD (per-content rich results).** Completes the structured-
+ data layer: every CMS page now emits a **BreadcrumbList** (Home → each path segment, the leaf labelled
+ with the page's real title, intermediate segments humanized), and every **blog article** emits a
+ **BlogPosting** node (headline, description, feature image via the media row, `datePublished`/
+ `dateModified`, author as a `Person`, `publisher`→Organization, `isPartOf`→WebSite) alongside its own
+ breadcrumb. Emitted as additional `@graph` blocks that cross-reference the site graph by `@id`, on the
+ same `tigerJsonLd` head placeholder — the blog hands the schema builder its presented article (it never
+ learns "blog"), and CMS pages derive the trail from the request path. Fail-soft; single-segment/home
+ pages emit no breadcrumb. Smoke asserts the seeded page carries a valid BreadcrumbList.
+
## [0.17.0-beta] — 2026-07-17
### Added
diff --git a/modules/blog/controllers/IndexController.php b/modules/blog/controllers/IndexController.php
index f8b808f..56e855a 100644
--- a/modules/blog/controllers/IndexController.php
+++ b/modules/blog/controllers/IndexController.php
@@ -74,6 +74,11 @@ public function viewAction()
'og_image_id' => isset($article['feature']['id']) ? (string) $article['feature']['id'] : '',
]);
}
+ // Article (BlogPosting) + BreadcrumbList structured data — the blog owns its URL/shape, so it
+ // hands the SEO schema builder the presented article rather than the builder learning "blog".
+ if (class_exists('Seo_Service_Schema')) {
+ Seo_Service_Schema::emitArticle($post, $article, $this->getRequest());
+ }
}
/**
diff --git a/modules/seo/plugins/Head.php b/modules/seo/plugins/Head.php
index 347dc0f..e510586 100644
--- a/modules/seo/plugins/Head.php
+++ b/modules/seo/plugins/Head.php
@@ -31,6 +31,9 @@ public function preDispatch(Zend_Controller_Request_Abstract $request)
$page = (new Tiger_Model_Page())->findById($pageId);
if ($page) {
Seo_Service_Head::forRow($page, $request);
+ if (class_exists('Seo_Service_Schema')) {
+ Seo_Service_Schema::emitPageBreadcrumb($request, (string) $page->title);
+ }
}
} catch (Throwable $e) {
// fail-open — a broken SEO lookup must never take down a page render
diff --git a/modules/seo/services/Schema.php b/modules/seo/services/Schema.php
index 1a2065f..cbbf18a 100644
--- a/modules/seo/services/Schema.php
+++ b/modules/seo/services/Schema.php
@@ -53,6 +53,61 @@ public static function emitSite(?Zend_Controller_Request_Abstract $request = nul
}
}
+ /**
+ * Emit a per-page BreadcrumbList derived from the URL path (Home → each path segment). The leaf's
+ * label is the page's own title (nicer than a humanized slug); intermediate segments humanize. A
+ * page one level deep or the homepage produces no breadcrumb (nothing to show but "Home").
+ *
+ * @param Zend_Controller_Request_Abstract|null $request the current request (path + base URL)
+ * @param string $leafName the current page's title (leaf label)
+ * @return void
+ */
+ public static function emitPageBreadcrumb(?Zend_Controller_Request_Abstract $request, $leafName)
+ {
+ try {
+ $base = self::_base($request);
+ $path = $request && method_exists($request, 'getPathInfo') ? (string) $request->getPathInfo() : '';
+ $trail = self::_trail($path, (string) $leafName, $base);
+ if (count($trail) < 2) {
+ return; // just "Home" — no breadcrumb worth emitting
+ }
+ self::_emit([self::_breadcrumbNode($trail, $base, $path)]);
+ } catch (Throwable $e) {
+ // fail-open
+ }
+ }
+
+ /**
+ * Emit an Article (BlogPosting) node + its BreadcrumbList for a blog article. Cross-references the
+ * site graph by @id (publisher → Organization, isPartOf → WebSite). Fail-soft: any missing piece is
+ * simply omitted.
+ *
+ * @param object $row the article `page` row (for updated_at)
+ * @param array $article the presented article (title/slug/excerpt/…)
+ * @param Zend_Controller_Request_Abstract|null $request the current request
+ * @return void
+ */
+ public static function emitArticle($row, array $article, ?Zend_Controller_Request_Abstract $request = null)
+ {
+ try {
+ $base = self::_base($request);
+ if ($base === '') {
+ return;
+ }
+ $nodes = [self::_articleNode($row, $article, $base, $request)];
+
+ // The article's breadcrumb: Home → Blog → (path-derived, leaf = the real title).
+ $path = '/' . ltrim((string) ($article['url'] ?? ('/blog/' . ($article['slug'] ?? ''))), '/');
+ $trail = self::_trail($path, (string) ($article['title'] ?? ''), $base);
+ if (count($trail) >= 2) {
+ $nodes[] = self::_breadcrumbNode($trail, $base, $path);
+ }
+ self::_emit($nodes);
+ } catch (Throwable $e) {
+ // fail-open
+ }
+ }
+
/** The brand entity: name, url, logo (real dimensions via the media row), and social `sameAs` links. */
private static function _organization($base, $request)
{
@@ -148,10 +203,121 @@ private static function _siteNavigation($base)
];
}
+ /** A BlogPosting node — headline, image, dates, author, publisher/isPartOf → the site graph. */
+ private static function _articleNode($row, array $article, $base, $request)
+ {
+ $slug = trim((string) ($article['slug'] ?? ''), '/');
+ $url = $base . '/blog/' . $slug;
+
+ $node = [
+ '@type' => 'BlogPosting',
+ '@id' => $url . '#article',
+ 'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $url],
+ 'headline' => (string) ($article['title'] ?? ''),
+ 'url' => $url,
+ 'isPartOf' => ['@id' => $base . '/#website'],
+ 'publisher' => ['@id' => $base . '/#organization'],
+ ];
+
+ $seoDesc = isset($article['seo']['description']) ? trim((string) $article['seo']['description']) : '';
+ $desc = $seoDesc !== '' ? $seoDesc : trim((string) ($article['excerpt'] ?? ''));
+ if ($desc !== '') {
+ $node['description'] = $desc;
+ }
+
+ // Image: the feature image, resolved through the media row for a real absolute URL + dimensions.
+ $imgRef = isset($article['feature']['id']) ? (string) $article['feature']['id'] : '';
+ $img = self::_image($imgRef !== '' ? $imgRef : self::_config('seo.og_image', ''), $request);
+ if ($img) {
+ $node['image'] = array_filter([
+ '@type' => 'ImageObject',
+ 'url' => $img['url'],
+ 'width' => $img['width'],
+ 'height' => $img['height'],
+ ], static function ($v) { return $v !== null && $v !== ''; });
+ }
+
+ $pub = self::_iso8601($article['published_at'] ?? '');
+ $mod = self::_iso8601(is_object($row) && isset($row->updated_at) ? $row->updated_at : '');
+ if ($pub !== '') { $node['datePublished'] = $pub; }
+ $node['dateModified'] = $mod !== '' ? $mod : ($pub !== '' ? $pub : null);
+ if ($node['dateModified'] === null) { unset($node['dateModified']); }
+
+ $author = trim((string) ($article['author']['name'] ?? ''));
+ $node['author'] = $author !== ''
+ ? ['@type' => 'Person', 'name' => $author]
+ : ['@id' => $base . '/#organization'];
+
+ return $node;
+ }
+
+ /** A BreadcrumbList node from a [ ['name','url'], … ] trail (each an absolute ListItem). */
+ private static function _breadcrumbNode(array $trail, $base, $path)
+ {
+ $items = [];
+ $pos = 1;
+ foreach ($trail as $step) {
+ $items[] = [
+ '@type' => 'ListItem',
+ 'position' => $pos++,
+ 'name' => (string) $step['name'],
+ 'item' => (string) $step['url'],
+ ];
+ }
+ return [
+ '@type' => 'BreadcrumbList',
+ '@id' => $base . '/' . ltrim((string) $path, '/') . '#breadcrumb',
+ 'itemListElement' => $items,
+ ];
+ }
+
// =====================================================================================
// Helpers
// =====================================================================================
+ /**
+ * Build a breadcrumb trail from a URL path: Home + one step per path segment, each with the
+ * absolute URL to that point. The last segment's label is $leafName (the real page title) when
+ * given, else a humanized slug; intermediate segments always humanize.
+ *
+ * @param string $path the request path (e.g. /blog/my-post)
+ * @param string $leafName the current page's title (leaf label; '' → humanize the slug)
+ * @param string $base the absolute site base (scheme://host)
+ * @return array [ ['name','url'], … ] starting at Home
+ */
+ private static function _trail($path, $leafName, $base)
+ {
+ $segments = array_values(array_filter(explode('/', trim((string) $path, '/')), static function ($s) { return $s !== ''; }));
+ $trail = [['name' => 'Home', 'url' => $base . '/']];
+ $acc = '';
+ $n = count($segments);
+ foreach ($segments as $i => $seg) {
+ $acc .= '/' . $seg;
+ $isLast = ($i === $n - 1);
+ $name = ($isLast && trim((string) $leafName) !== '') ? trim((string) $leafName) : self::_humanize($seg);
+ $trail[] = ['name' => $name, 'url' => $base . $acc];
+ }
+ return $trail;
+ }
+
+ /** Turn a slug into a human label: "getting-started" → "Getting Started". */
+ private static function _humanize($slug)
+ {
+ $s = str_replace(['-', '_'], ' ', (string) $slug);
+ return ucwords(trim($s));
+ }
+
+ /** A DB DATETIME ('Y-m-d H:i:s') as an ISO-8601 string; '' if blank/unparseable. */
+ private static function _iso8601($datetime)
+ {
+ $datetime = trim((string) $datetime);
+ if ($datetime === '') {
+ return '';
+ }
+ $ts = strtotime($datetime);
+ return $ts !== false ? date('c', $ts) : '';
+ }
+
/** Serialize the nodes as one `@graph` and append the ld+json