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("#<script type=\"application/ld\+json\">(.*?)</script>#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("#<script type=\"application/ld\+json\">(.*?)</script>#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 → <title> (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 <script> to the head placeholder. */ private static function _emit(array $nodes) {