From 56af1fd28f4b7be873eb5a62743a1ff32f0f1e22 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 17 Jul 2026 12:03:46 -0400 Subject: [PATCH] TigerSEO: JSON-LD site-identity graph (Organization + WebSite + SiteNavigation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich-results / sitelinks layer. Where Seo_Service_Head describes one page, the new Seo_Service_Schema describes the SITE as an entity — a single ld+json @graph: - Organization — brand name, logo (media row → real absolute URL + dimensions), sameAs social links. Config: tiger.site.name / tiger.site.logo / tiger.seo.social.*. - WebSite — publisher → Organization; optional SearchAction (sitelinks searchbox) when tiger.seo.schema.search_url is configured. - SiteNavigationElement — the primary menu (Tiger_Menu::getData), so Google can map site structure into nav sitelinks. Emitted once per request (the service latches) by Seo_Plugin_Head into a process-wide 'tigerJsonLd' Zend_View placeholder that the public layout renders — same contributed-head seam as the OG tags, no core edit, degrades to nothing without the module. Fail-soft throughout; < and > are hex-escaped via JSON_HEX_TAG so no value can break out of the #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 + fi + # 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. check "sitemap.xml route" 200 "http://127.0.0.1:8000/sitemap.xml" diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e99f5..f88b875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +### Added +- **TigerSEO — JSON-LD structured data (rich results / sitelinks layer).** Where the head meta/OG tags + describe one *page*, this describes the *site as an entity*: a single `' + ); + } + + /** The site name (tiger.site.name), with a neutral fallback so the brand node is never nameless. */ + private static function _siteName() + { + $name = trim((string) self::_config('site.name', '')); + return $name !== '' ? $name : 'Tiger'; + } + + /** Social profile URLs for Organization.sameAs, from tiger.seo.social.* (or tiger.site.social.*). */ + private static function _sameAs() + { + $out = []; + foreach (['twitter', 'facebook', 'instagram', 'linkedin', 'youtube', 'github'] as $k) { + $v = trim((string) self::_config('seo.social.' . $k, self::_config('site.social.' . $k, ''))); + if ($v !== '') { + $out[] = $v; + } + } + return array_values(array_unique($out)); + } + + /** The absolute site base (scheme://host, no trailing slash) — request-derived, else tiger.site.url. */ + private static function _base(?Zend_Controller_Request_Abstract $request) + { + if ($request && method_exists($request, 'getScheme')) { + return $request->getScheme() . '://' . $request->getHttpHost(); + } + return rtrim((string) self::_config('site.url', ''), '/'); + } + + /** A Zend_View to reach the placeholder helper. Any instance shares the process-wide registry. */ + private static function _view() + { + if (Zend_Registry::isRegistered('Zend_View')) { + $v = Zend_Registry::get('Zend_View'); + if ($v instanceof Zend_View_Interface) { + return $v; + } + } + return new Zend_View(); + } + + /** Read a `tiger.` config value (org-cascaded, live) with a default. */ + private static function _config($dotKey, $default = '') + { + if (!Zend_Registry::isRegistered('Zend_Config')) { + return $default; + } + $node = Zend_Registry::get('Zend_Config')->get('tiger'); + foreach (explode('.', $dotKey) as $seg) { + if (!($node instanceof Zend_Config)) { return $default; } + $node = $node->get($seg); + if ($node === null) { return $default; } + } + return is_scalar($node) ? (string) $node : $default; + } + + /** + * Resolve an image reference to ['url','width','height','mime','alt'] — a `media_id` (looked up for a + * real absolute URL + true pixel dimensions) or an already-absolute URL. Null when unresolvable. + */ + private static function _image($ref, $request) + { + $ref = trim((string) $ref); + if ($ref === '') { + return null; + } + if (preg_match('#^https?://#i', $ref)) { + return ['url' => $ref, 'width' => null, 'height' => null, 'mime' => null, 'alt' => null]; + } + try { + if (!class_exists('Tiger_Model_Media')) { return null; } + $model = new Tiger_Model_Media(); + $row = $model->findById($ref); + if (!$row) { return null; } + $arr = $row->toArray(); + $url = (string) $model->url($arr); + if ($url === '') { return null; } + if (!preg_match('#^https?://#i', $url) && $request && method_exists($request, 'getScheme')) { + $url = $request->getScheme() . '://' . $request->getHttpHost() . '/' . ltrim($url, '/'); + } + return [ + 'url' => $url, + 'width' => $arr['width'] ?? null, + 'height' => $arr['height'] ?? null, + 'mime' => $arr['mime_type'] ?? null, + 'alt' => $arr['alt_text'] ?? ($arr['title'] ?? null), + ]; + } catch (Throwable $e) { + return null; + } + } +} diff --git a/themes/puma/layouts/scripts/layout.phtml b/themes/puma/layouts/scripts/layout.phtml index 167e994..4ff0378 100644 --- a/themes/puma/layouts/scripts/layout.phtml +++ b/themes/puma/layouts/scripts/layout.phtml @@ -50,6 +50,7 @@ $_lang = defined('LANG') ? LANG : 'en'; codeInject('head') ?> headLink() ?> +placeholder('tigerJsonLd') ?> pageHead ?? '' ?>