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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows
[Keep a Changelog](https://keepachangelog.com/); this project uses [SemVer](https://semver.org/)
— while `0.x`, the public API (`@api`) may still shift between minor versions.

## [Unreleased]

### Added
- **TigerSEO — Phase 1 (the head registry).** A new bundled `seo` module makes the data Tiger already
collects actually reach the `<head>`. Core hygiene: the public PUMA layout now renders through TigerZF's
own `headTitle`/`headMeta`/`headLink` placeholder containers (previously hardcoded `<title>` + a raw
`pageHead` echo, with no meta description ever emitted). `Seo_Service_Head` maps a page's unified
`meta.seo` onto those containers — **title, meta description, robots (`noindex`/`nofollow` only when
restricted), and canonical (explicit or self-referencing)** — reached by `Seo_Plugin_Head` for CMS pages
and a `class_exists`-guarded call from the blog article controller. Uninstall the module and the head
still renders, with less. (OG/Twitter, hreflang, sitemaps, JSON-LD are later phases.)
- **Migrations can run PHP.** `Tiger_Db_Migrator` now executes a `fn($db)` step as well as an SQL string
(split by type, so a SQL string is never mistaken for a callable) — for DATA migrations SQL can't
express cleanly.

### Changed
- **Unified page SEO metadata onto `page.meta.seo`.** The CMS wrote a flat `meta.description`; the blog
already wrote nested `meta.seo`. The CMS now writes `meta.seo.description`, and migration `0032` moves
existing rows — one shape, not two (no tolerant reader kept around to preserve the split forever).

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

### Fixed
Expand Down
16 changes: 9 additions & 7 deletions core/controllers/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,18 @@ public function viewAction()
$decoded = is_array($page->meta) ? $page->meta : json_decode((string) $page->meta, true);
if (is_array($decoded)) { $meta = $decoded; }
}
$head = (string) ($meta['head_html'] ?? '');
$head = (string) ($meta['head_html'] ?? ''); // admin-authored raw <head> — the escape hatch
$scripts = (string) ($meta['body_scripts'] ?? '');
$desc = trim((string) ($meta['description'] ?? ''));
if ($desc !== '') {
$head = '<meta name="description" content="' . htmlspecialchars($desc, ENT_QUOTES) . '">' . "\n" . $head;
}
// The meta description (and other SEO) is no longer synthesized here — it lives in meta.seo and is
// rendered through the head registry by TigerSEO (Seo_Plugin_Head → headMeta/headLink).

if (!empty($page->layout_key)) {
// Self-contained CMS layout owns the whole document — splice head/scripts into it.
if ($head !== '') { $html = self::_injectBefore($html, '</head>', $head); }
// Self-contained CMS layout owns the whole document (it disables the theme layout, so the head
// registry the theme renders never reaches it) — splice the SEO head that Seo_Plugin_Head
// populated from meta.seo, plus the admin head_html, into its own </head>.
$seoHead = trim((string) $this->view->headMeta() . (string) $this->view->headLink());
$inject = trim($seoHead . "\n" . $head);
if ($inject !== '') { $html = self::_injectBefore($html, '</head>', $inject); }
if ($scripts !== '') { $html = self::_injectBefore($html, '</body>', $scripts); }
$this->_helper->layout()->disableLayout();
$this->_helper->viewRenderer->setNoRender(true);
Expand Down
27 changes: 23 additions & 4 deletions library/Tiger/Db/Migrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ public function migrate($log = null)
continue;
}
$this->emit($log, " migrating {$version}_{$m['name']} ...");
foreach ($m['up'] as $sql) {
$this->db->query($sql);
foreach ($m['up'] as $stmt) {
$this->run($stmt);
}
// Record only after every statement succeeded (see class caveat).
$this->db->insert('tiger_migration', [
Expand Down Expand Up @@ -103,15 +103,34 @@ public function rollback($steps = 1, $log = null)
}
$m = $all[$version];
$this->emit($log, " rolling back {$version}_{$m['name']} ...");
foreach ($m['down'] as $sql) {
$this->db->query($sql);
foreach ($m['down'] as $stmt) {
$this->run($stmt);
}
$this->db->delete('tiger_migration', $this->db->quoteInto('version = ?', $version));
$done[$version] = $m['name'];
}
return $done;
}

/**
* Run one migration step. A **string** is executed as SQL (the common case). A **callable**
* (a `function ($db) { … }` — anything non-string that's callable) is invoked with the DB adapter,
* for DATA migrations SQL can't express cleanly (e.g. transforming a JSON column across rows). The
* string-vs-callable split is by type, not `is_callable`, so a SQL string that happens to name a
* PHP function is never mistaken for one.
*
* @param string|callable $stmt an SQL string, or fn(Zend_Db_Adapter_Abstract $db): void
* @return void
*/
protected function run($stmt)
{
if (!is_string($stmt) && is_callable($stmt)) {
$stmt($this->db);
return;
}
$this->db->query($stmt);
}

/**
* Discovered vs applied, for a `migrate:status` view.
*
Expand Down
40 changes: 40 additions & 0 deletions migrations/0032_page_meta_seo_unify.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Unify page SEO metadata onto `page.meta.seo`. The CMS wrote a flat `meta.description`; the blog
* already writes the nested `meta.seo.{title,description,…}` shape. This moves every existing
* `meta.description` into `meta.seo.description` (creating `seo` if absent, never clobbering an existing
* one) and drops the flat key — so there is ONE shape, not two. (A reader that tolerates both shapes
* forever is how you keep both shapes forever; unify the data instead.)
*
* DATA migration — a PHP callable (Tiger_Db_Migrator runs an SQL string OR a fn($db)). Touches the live
* `page` rows only; historical `page_version` snapshots keep their shape (the resolver reads live meta).
* One-way: `down` is a no-op, because a reversal couldn't tell a moved description from a blog article's
* natively-nested one.
*/
return [
'up' => [
function ($db) {
$rows = $db->fetchAll("SELECT page_id, meta FROM page WHERE meta IS NOT NULL AND meta <> ''");
foreach ($rows as $r) {
$meta = json_decode((string) $r['meta'], true);
if (!is_array($meta) || !array_key_exists('description', $meta)) {
continue; // nothing flat to move (blog rows, already-unified rows)
}
$desc = trim((string) $meta['description']);
unset($meta['description']);
if ($desc !== '') {
if (!isset($meta['seo']) || !is_array($meta['seo'])) { $meta['seo'] = []; }
if (empty($meta['seo']['description'])) { $meta['seo']['description'] = $desc; }
}
$db->update(
'page',
['meta' => json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)],
$db->quoteInto('page_id = ?', $r['page_id'])
);
}
},
],
'down' => [],
];
8 changes: 8 additions & 0 deletions modules/blog/controllers/IndexController.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ public function viewAction()
$this->view->article = $article;
$this->view->title = $article['seo']['title'] !== '' ? $article['seo']['title'] : $post->title;
$this->view->metaDescription = $article['seo']['description'] !== '' ? $article['seo']['description'] : $article['excerpt'];

// Contribute the article's SEO to the head registry (title/description/robots/canonical). An
// article renders via this controller (not PageDispatch), so it can't rely on Seo_Plugin_Head —
// it calls the resolver directly, guarded so the blog never hard-depends on the SEO module. The
// excerpt is the description fallback when the author set no SEO description.
if (class_exists('Seo_Service_Head')) {
Seo_Service_Head::forRow($post, $this->getRequest(), ['description' => $article['excerpt']]);
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion modules/cms/controllers/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ protected function _editValues($page)
'layout_key' => $page->layout_key,
'published_at' => $page->published_at,
'body' => $page->body,
'meta_description' => $meta['description'] ?? '',
'meta_description' => $meta['seo']['description'] ?? ($meta['description'] ?? ''),
'head_html' => $meta['head_html'] ?? '',
'body_scripts' => $meta['body_scripts'] ?? '',
];
Expand Down
7 changes: 6 additions & 1 deletion modules/cms/services/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@ public function save(array $params): void
if (is_array($decoded)) { $meta = $decoded; }
}
}
$meta['description'] = trim((string) ($v['meta_description'] ?? ''));
// SEO description lives under the unified `meta.seo` shape (same as blog articles); the flat
// legacy `meta.description` is dropped (migration 0032 moved existing rows). head_html/body_scripts
// stay flat — they're the raw admin-authored escape hatch, not SEO metadata.
if (!isset($meta['seo']) || !is_array($meta['seo'])) { $meta['seo'] = []; }
$meta['seo']['description'] = trim((string) ($v['meta_description'] ?? ''));
unset($meta['description']);
$meta['head_html'] = (string) ($v['head_html'] ?? '');
$meta['body_scripts'] = (string) ($v['body_scripts'] ?? '');
$data['meta'] = json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
Expand Down
81 changes: 81 additions & 0 deletions modules/seo/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# AGENTS.md — writing code in TigerSEO

Instructions for an AI assistant (or a new human contributor) building this module. **TigerSEO is a
Tiger module first**, so the platform conventions win by default.

**Read these first, and follow them over anything here:**
`tiger-core/AGENTS.md` (house conventions — short arrays, docblocks, i18n keys, the `/api`
validate→transaction flow, no page-POSTs) · `tiger-core/ADMIN.md` (the admin-screen template — copy
it; don't invent a shell) · `tiger-core/ROUTING.md` (declare route overrides; never `addRoute` an
alias) · `tiger-core/WEBSERVICES.md` (the `/api` message pattern).

This file covers only what's **different** because this is SEO. For the *why* behind everything below
read [ARCHITECTURE.md](ARCHITECTURE.md) — and if you're about to make it cleverer, read §10 first.
Most "obvious improvements" here are things we already said no to, with reasons.

## The five rules that are not negotiable

1. **The head registry is core's, not ours.** Contribute through `$this->headTitle()` /
`headMeta()` / `headLink()`. **Never** build a TigerSEO head abstraction, and never make a theme
depend on this module to render a title tag. Uninstall TigerSEO → the head still renders. That's
the test (§1).
2. **Never write a file into the docroot.** `robots.txt` and `sitemap.xml` are **routes**. A physical
file in `public/` is silently served by Apache before PHP ever runs (`.htaccess` real-file-first),
so a static file doesn't just violate the module rule — it *breaks the feature invisibly*. Caches
go in `var/`. (§5)
3. **Never touch `.htaccess` or any web-server config.** RankMath does. We don't, ever, for any
reason. That's a platform-wide rule, not an SEO one.
4. **Never learn a content type.** No `if ($type === 'product')`, no table of known post types. The
module that owns a content type contributes its own schema through the registry. If you're editing
TigerSEO to support someone else's content, the design has failed (§4).
5. **No phone-home, no account, no upsell, no pro tier.** It's free and BSD. We hold this line for
themes already; SEO doesn't get an exemption because the category's incumbents all do it.

## Storage: `page.meta.seo`, and nowhere else

- **Page SEO lives in `page.meta.seo`** — one shape, for CMS pages and blog articles alike. Not a
`seo` table: it's 1:1 with the row, already loaded, versioned free by `page_version`, org-cascaded
free (§3).
- **Settings live in `config`** — the live-override tier, per-org, no deploy. **Never** an options
blob, never a settings table ([[config-discipline]]).
- **The `option` table is not for this.** That store is lazy per-user/entity state (a dashboard
layout, a dismissed nag). SEO meta is eager and intrinsic to the row.
- **Unify, don't tolerate.** The CMS writes `meta.description` and the blog writes
`meta.seo.description` today. Fix it with a **migration**, not a reader that accepts both — a
tolerant reader is how a codebase keeps two shapes forever.

## Render data before you add fields

The first phase is not features. Tiger already asks authors for a meta description, a canonical URL,
and an OG image — **and renders none of them.** Fix that before adding a single new input. Any PR that
adds a field while an existing field still doesn't render is pointed the wrong way.

## Conventions specific to this module

- **Slug is `seo`;** classes are `Seo_*` (PSR-0 underscore — ZF1 mandates it for controllers/module
classes).
- **Routes are declared overrides** (`Tiger_Routing_Overrides`), not `addRoute`. Verify early that a
dotted path (`robots.txt`) survives the prefix matcher.
- **Sitemap/robots generation is cached** behind a cheap fingerprint (max `updated_at` + row count,
per org + locale), in `var/`. **No cron** — cPanel has none. Rebuild on miss, self-heal.
- **Every emitted tag is escaped.** This module's entire job is putting user-authored strings into
`<head>`, which is an XSS surface with a bow on it. `head_html` is trusted-by-policy (admin-only,
like `phtml` pages); *everything else* is escaped, always, no exceptions for "it's just a title."
- **Admin screens** follow ADMIN.md exactly; settings register into the shared Settings tree.
- **All strings are `seo.*` translate keys.** Including admin labels and guidance copy.
- **Length guidance is pixel-width, not character count** — a character count is a lie that ships in
every competitor.

## Anti-patterns (don't)

- Don't build a head abstraction; use `Zend_View_Helper_Head*`.
- Don't make a theme or core depend on TigerSEO for basic head rendering.
- Don't write `robots.txt`, `sitemap.xml`, or anything else into the docroot.
- Don't edit `.htaccess` or any web-server config.
- Don't add a `seo` table for page metadata, or a route-keyed one for module URLs.
- Don't put settings in a new table or a serialized blob — `config` rows.
- Don't hardcode knowledge of another module's content type.
- Don't emit an unescaped user string into `<head>` (except the declared `head_html` hatch).
- Don't rely on cron.
- Don't add a field while an existing collected field still isn't rendered.
- Don't build the SEO score in v1 — and when you do, don't let it block publishing (§8).
Loading
Loading