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'; codeInject('head') ?> headLink() ?> +placeholder('tigerJsonLd') ?> pageHead ?? '' ?>