Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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("#<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
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"
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script type="application/ld+json">`
`@graph` carrying **Organization** (brand: name, logo via the media row with real dimensions, social
`sameAs`), **WebSite** (publisher → Organization; an optional **SearchAction**/sitelinks-searchbox when
`tiger.seo.schema.search_url` is set), and **SiteNavigationElement** (the primary menu, so Google can map
site structure → sitelinks). Emitted once per request by `Seo_Service_Schema` into a `tigerJsonLd` head
placeholder the public layout renders — no core edit, gone when the module is absent. Config-driven
(`tiger.site.name`/`.logo`, `tiger.seo.social.*`, `tiger.seo.schema.*`) and fail-soft: a missing name,
logo, or menu just omits that node. `<`/`>` are hex-escaped (`JSON_HEX_TAG`) so a value can never break
out of the `<script>`. (BreadcrumbList + Article are the next slice.)

## [0.15.0-beta] — 2026-07-17

### Added
Expand Down
9 changes: 8 additions & 1 deletion modules/seo/plugins/Head.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ class Seo_Plugin_Head extends Zend_Controller_Plugin_Abstract
*/
public function preDispatch(Zend_Controller_Request_Abstract $request)
{
// Site-identity structured data (Organization + WebSite + SiteNavigationElement) — the same for
// every page, emitted once (the service latches). Independent of whether this is a CMS page, so
// it rides every public render; non-public layouts simply don't output the placeholder.
if (class_exists('Seo_Service_Schema')) {
Seo_Service_Schema::emitSite($request);
}

$pageId = (string) $request->getParam('cms_page_id', '');
if ($pageId === '') {
return; // not a CMS page dispatch
return; // not a CMS page dispatch — no per-page head to build
}
try {
$page = (new Tiger_Model_Page())->findById($pageId);
Expand Down
261 changes: 261 additions & 0 deletions modules/seo/services/Schema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Seo_Service_Schema — contributes JSON-LD structured data (schema.org) to the head. Where the head
* meta/OG tags (Seo_Service_Head) describe ONE page, this describes the SITE as an entity: the
* Organization (brand), the WebSite, and the primary SiteNavigationElement — the graph Google reads to
* build the brand knowledge panel and **sitelinks**. Emitted once per request as a single
* `<script type="application/ld+json">` block holding an `@graph` of cross-referenced nodes.
*
* Like Seo_Service_Head it appends into a process-wide Zend_View placeholder (`tigerJsonLd`) which the
* public layout renders — so no core edit, and it degrades to nothing when the module is absent. It is
* NOT a Tiger_Service_Service, so it is unreachable over /api (the gateway's type guard blocks it); the
* plugin calls it in-process. Everything is config-driven and fail-soft: a missing site name, logo, or
* menu simply omits that part rather than breaking the graph or the page.
*
* @api
* @see Seo_Service_Head
*/
class Seo_Service_Schema
{
/** Emit-once latch: the site graph is identical for every page, so it renders a single time per request. */
private static $_emitted = false;

/**
* Emit the site-identity graph (Organization + WebSite + SiteNavigationElement) into the head, once.
*
* @param Zend_Controller_Request_Abstract|null $request the current request (for the absolute base URL)
* @return void
*/
public static function emitSite(?Zend_Controller_Request_Abstract $request = null)
{
if (self::$_emitted) {
return;
}
self::$_emitted = true;

try {
$base = self::_base($request);
if ($base === '') {
return; // no absolute base to anchor @id/url on — skip rather than emit relative nodes
}
$nodes = array_values(array_filter([
self::_organization($base, $request),
self::_website($base),
self::_siteNavigation($base),
]));
if ($nodes) {
self::_emit($nodes);
}
} catch (Throwable $e) {
// fail-open — structured data must never take down a page render
}
}

/** The brand entity: name, url, logo (real dimensions via the media row), and social `sameAs` links. */
private static function _organization($base, $request)
{
$node = [
'@type' => 'Organization',
'@id' => $base . '/#organization',
'name' => self::_siteName(),
'url' => $base . '/',
];

// Logo: the Site Identity logo (tiger.site.logo), else the SEO share image (tiger.seo.og_image).
$logo = self::_image(self::_config('site.logo', self::_config('seo.og_image', '')), $request);
if ($logo) {
$node['logo'] = array_filter([
'@type' => 'ImageObject',
'@id' => $base . '/#logo',
'url' => $logo['url'],
'width' => $logo['width'],
'height' => $logo['height'],
], static function ($v) { return $v !== null && $v !== ''; });
}

$sameAs = self::_sameAs();
if ($sameAs) {
$node['sameAs'] = $sameAs;
}
return $node;
}

/** The website node — publisher points at the Organization; an optional SearchAction = a sitelinks searchbox. */
private static function _website($base)
{
$node = [
'@type' => 'WebSite',
'@id' => $base . '/#website',
'url' => $base . '/',
'name' => self::_siteName(),
'publisher' => ['@id' => $base . '/#organization'],
];

// Sitelinks searchbox — only when the operator has a public search URL template configured.
// e.g. tiger.seo.schema.search_url = "/search?q={search_term_string}"
$search = trim((string) self::_config('seo.schema.search_url', ''));
if ($search !== '') {
$target = preg_match('#^https?://#i', $search) ? $search : $base . '/' . ltrim($search, '/');
$node['potentialAction'] = [
'@type' => 'SearchAction',
'target' => ['@type' => 'EntryPoint', 'urlTemplate' => $target],
'query-input' => 'required name=search_term_string',
];
}
return $node;
}

/** The primary nav as a SiteNavigationElement — helps Google map site structure (→ sitelinks). */
private static function _siteNavigation($base)
{
if (!class_exists('Tiger_Menu')) {
return null;
}
$key = trim((string) self::_config('seo.schema.nav_menu', 'primary'));
if ($key === '') {
$key = 'primary';
}
try {
$tree = Tiger_Menu::getData($key);
} catch (Throwable $e) {
return null;
}
if (!is_array($tree) || !$tree) {
return null;
}

$names = [];
$urls = [];
foreach ($tree as $item) { // top-level items only — that's what nav sitelinks use
$label = trim((string) ($item['label'] ?? ''));
$href = (string) ($item['href'] ?? '');
if ($label === '' || $href === '' || $href === '#') {
continue; // skip headings + dead placeholders
}
$names[] = $label;
$urls[] = preg_match('#^https?://#i', $href) ? $href : $base . '/' . ltrim($href, '/');
}
if (!$names) {
return null;
}
return [
'@type' => 'SiteNavigationElement',
'@id' => $base . '/#sitenav',
'name' => $names,
'url' => $urls,
];
}

// =====================================================================================
// Helpers
// =====================================================================================

/** Serialize the nodes as one `@graph` and append the ld+json <script> to the head placeholder. */
private static function _emit(array $nodes)
{
$graph = ['@context' => 'https://schema.org', '@graph' => array_values($nodes)];
// JSON_HEX_TAG encodes every < and > as </>, so a value can never break out of the
// <script> element (parsers decode them back) — the safe way to inline JSON-LD.
$json = json_encode($graph, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG);
if ($json === false) {
return;
}
self::_view()->placeholder('tigerJsonLd')->append(
'<script type="application/ld+json">' . $json . '</script>'
);
}

/** 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.<dotKey>` 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;
}
}
}
1 change: 1 addition & 0 deletions themes/puma/layouts/scripts/layout.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ $_lang = defined('LANG') ? LANG : 'en';
<link rel="stylesheet" href="<?= $this->asset($this->themeAssets . '/custom.css') ?>">
<?= $this->codeInject('head') ?>
<?= $this->headLink() ?><?php /* SEO/link registry: rel=canonical, hreflang, … (contributed, e.g. by TigerSEO) */ ?>
<?= $this->placeholder('tigerJsonLd') ?><?php /* JSON-LD structured data: Organization/WebSite/SiteNavigation (TigerSEO) */ ?>
<?= $this->pageHead ?? '' ?><?php /* per-page raw head from the CMS page meta (admin-authored) — the escape hatch */ ?>
</head>
<body>
Expand Down
Loading