diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml
index 296f3c6..9521fa5 100644
--- a/.github/workflows/smoke.yml
+++ b/.github/workflows/smoke.yml
@@ -163,6 +163,16 @@ 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.
+ 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
+ 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';
= $this->codeInject('head') ?>
= $this->headLink() ?>
+= $this->placeholder('tigerJsonLd') ?>
= $this->pageHead ?? '' ?>