diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2474be3..6c0893c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
`. Core hygiene: the public PUMA layout now renders through TigerZF's
+ own `headTitle`/`headMeta`/`headLink` placeholder containers (previously hardcoded `` + 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
diff --git a/core/controllers/PageController.php b/core/controllers/PageController.php
index cfb1f96..20fc858 100644
--- a/core/controllers/PageController.php
+++ b/core/controllers/PageController.php
@@ -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 — the escape hatch
$scripts = (string) ($meta['body_scripts'] ?? '');
- $desc = trim((string) ($meta['description'] ?? ''));
- if ($desc !== '') {
- $head = '' . "\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); }
+ // 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 .
+ $seoHead = trim((string) $this->view->headMeta() . (string) $this->view->headLink());
+ $inject = trim($seoHead . "\n" . $head);
+ if ($inject !== '') { $html = self::_injectBefore($html, '', $inject); }
if ($scripts !== '') { $html = self::_injectBefore($html, '
= $this->render('_partials/public-header.phtml') ?>
', $scripts); }
$this->_helper->layout()->disableLayout();
$this->_helper->viewRenderer->setNoRender(true);
diff --git a/library/Tiger/Db/Migrator.php b/library/Tiger/Db/Migrator.php
index b2dc8f1..98b1411 100644
--- a/library/Tiger/Db/Migrator.php
+++ b/library/Tiger/Db/Migrator.php
@@ -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', [
@@ -103,8 +103,8 @@ 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'];
@@ -112,6 +112,25 @@ public function rollback($steps = 1, $log = null)
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.
*
diff --git a/migrations/0032_page_meta_seo_unify.php b/migrations/0032_page_meta_seo_unify.php
new file mode 100644
index 0000000..355d246
--- /dev/null
+++ b/migrations/0032_page_meta_seo_unify.php
@@ -0,0 +1,40 @@
+ [
+ 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' => [],
+];
diff --git a/modules/blog/controllers/IndexController.php b/modules/blog/controllers/IndexController.php
index f8ea2df..ca8d16c 100644
--- a/modules/blog/controllers/IndexController.php
+++ b/modules/blog/controllers/IndexController.php
@@ -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']]);
+ }
}
/**
diff --git a/modules/cms/controllers/PageController.php b/modules/cms/controllers/PageController.php
index f4454cc..6e2f8e1 100644
--- a/modules/cms/controllers/PageController.php
+++ b/modules/cms/controllers/PageController.php
@@ -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'] ?? '',
];
diff --git a/modules/cms/services/Page.php b/modules/cms/services/Page.php
index c76b855..3706950 100644
--- a/modules/cms/services/Page.php
+++ b/modules/cms/services/Page.php
@@ -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);
diff --git a/modules/seo/AGENTS.md b/modules/seo/AGENTS.md
new file mode 100644
index 0000000..baf3971
--- /dev/null
+++ b/modules/seo/AGENTS.md
@@ -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
+ `
`, 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 `
` (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).
diff --git a/modules/seo/ARCHITECTURE.md b/modules/seo/ARCHITECTURE.md
new file mode 100644
index 0000000..d342190
--- /dev/null
+++ b/modules/seo/ARCHITECTURE.md
@@ -0,0 +1,265 @@
+# TigerSEO — Architecture & Rationale
+
+This document explains **why TigerSEO is built the way it is**. It favors *rationale* over reference.
+Read [§10 Rejected alternatives](#10-rejected-alternatives-so-we-dont-relitigate-them) before
+"improving" anything here.
+
+> **Status: design-of-record (proposed, not built).** Where this says "TigerSEO does X," that's the
+> target behavior. §0 is the audit of what exists today and is the *reason* for most of what follows.
+
+Reference model: **RankMath** — the feature set is right, the architecture is the cautionary tale.
+Where this doc says "RankMath does X," that's a thing we're deliberately not doing.
+
+---
+
+## 0. The audit: what Tiger has today (2026-07-17)
+
+Verified by grep, not memory. This is the starting line:
+
+| Thing | State |
+|---|---|
+| `` | **never emitted** — the public layout is `charset`, `viewport`, `
`, full stop |
+| `robots.txt`, `sitemap.xml`, `og:*`, `twitter:card`, JSON-LD, `rel=canonical`, `noindex` | **zero occurrences** anywhere in the codebase |
+| `Zend_View_Helper_HeadMeta` / `HeadTitle` / `HeadLink` / `Placeholder` | **ship in TigerZF; used by nothing** |
+| CMS page SEO fields | `page.meta.description`, `.head_html`, `.body_scripts` — collected, **description never rendered** |
+| Blog article SEO fields | `page.meta.seo.{seo_title, seo_description, og_image_id, canonical}` — collected, **never rendered** |
+| `page_redirect` | **built** — `from_slug`/`to_slug`/`locale`/`code` (301\|302), org-scoped, auto-written on slug change, dispatched by a plugin. No UI. |
+| `media.alt_text` | **built** |
+| Locale-prefix routes (`/es/…`) + language-only locales | **built** |
+
+Two findings drive this whole document.
+
+**Finding 1 — the data is captured and thrown away.** An author can fill in a canonical URL and an OG
+image on an article, save it, and nothing renders. That's not a missing feature; it's a broken
+promise already shipped. And the two collectors disagree: the CMS writes `meta.description`, the blog
+writes `meta.seo.description`. **Same column, two shapes, neither rendered.** Unify before extending.
+
+**Finding 2 — the head abstraction already exists and Tiger ignored it.** ZF1's placeholder helpers
+have been in the engine the whole time. Tiger's layouts hardcode `` and paste a raw
+`head_html` blob instead. So the foundational task isn't "build a head registry" — it's **adopt the
+one in the box**, which is platform hygiene that TigerSEO merely benefits from (§1).
+
+---
+
+## 1. The `` is a registry — and it's core's, not TigerSEO's
+
+**The single most important structural decision: the head registry belongs to tiger-core, not to this
+module.** A theme needs `` and `` whether or not TigerSEO is installed. If TigerSEO owned
+the head, every theme would depend on an SEO module to render a title tag — absurd, and a coupling
+that would never come out again.
+
+So the work splits cleanly:
+
+| Where | What | Depends on |
+|---|---|---|
+| **tiger-core** (PUMA layouts) | call `$this->headTitle()` / `$this->headMeta()` / `$this->headLink()` instead of hardcoding `` and echoing `pageHead` | nothing — it's using TigerZF's own helpers |
+| **TigerSEO** | *append to those containers* — description, OG, Twitter, canonical, hreflang, robots | tiger-core only |
+
+**Neither side learns about the other.** Core doesn't know SEO exists; TigerSEO registers no custom
+registry. Uninstall TigerSEO and the head still renders — just with less in it. That's the test a
+correct design passes.
+
+**Why a registry at all**, rather than the theme rendering whatever the page hands it: because more
+than one party has a legitimate claim on the `` — the theme (viewport, fonts, preloads), a
+module (TigerDocs' search, a widget's CSS), the page author (`head_html`), and TigerSEO. A string
+concat has no conflict resolution and no ordering; a container has both, and ZF1's already implements
+`set` / `append` / `prepend` semantics. **Last authority wins, deterministically, and you can ask what
+it decided** — the same explainability instinct as the ACL simulator.
+
+**`head_html` is not removed.** It stays as the escape hatch for the genuinely weird (a verification
+tag, a one-off vendor snippet), because there is always one. It just stops being the *only* road. Per
+the house preference: add the new alongside the old, don't bury what works.
+
+---
+
+## 2. What "more cleanly than RankMath" actually means
+
+RankMath's feature list is largely correct — it won by shipping what Yoast gated. Its *architecture*
+is the thing to avoid, and naming the specific sins keeps us honest:
+
+| RankMath | Why it's like that | TigerSEO |
+|---|---|---|
+| Enormous serialized blobs in `wp_options`, autoloaded every request | WP has no config tier | `config` rows — the live-override tier, per-org, lean ([[config-discipline]]) |
+| ~2,000 stringly-typed hooks | WP is procedural; hooks are its only seam | typed registries + module-contributed schema (§4) |
+| Knows every content type itself (WooCommerce, EDD, …) | no way for a plugin to describe itself | **content types describe themselves** — blog owns `Article` (§4) |
+| Edits `.htaccess` and writes `robots.txt` into the docroot | WP's install model permits it | **never** — a module touches no web-server config and writes no docroot file (§5) |
+| Phones home; account required for some features | growth loop | never |
+| In-admin upsell on every screen | freemium | it's free, BSD, done |
+| Single-site | WP is single-tenant | per-org, because `config` already is (§7) |
+| SEO score trains you to write for a checklist | it demos well | deferred, and scoped honestly (§8) |
+
+**The thesis in one line: SEO output should be a *rendering* concern of the platform, not a plugin
+stapled to the side of it.** Everything above follows from taking that literally.
+
+---
+
+## 3. Storage: `page.meta.seo` — no new table
+
+SEO metadata for a page lives in the **`page.meta` JSON** the page already carries, under one `seo`
+key. No `seo` table, no join.
+
+The reasoning is [[config-discipline]] applied to content — **split by access pattern**:
+
+- SEO meta is read on **every public render**, and always for **exactly the row being rendered**. It's
+ 1:1 with the page and already in memory the moment the page loads. A `seo` table would add a join to
+ every page view to fetch data the page row could have carried for free.
+- It's **versioned for free** by `page_version` — restore a page, restore its SEO with it. A side
+ table would silently not do that, and nobody would notice until they restored a version and lost
+ their canonical.
+- It's **org-cascaded for free** — the page store already resolves tenant-over-global.
+- It is **not** `option`-table material: that store is for lazy, on-demand, per-user/entity state
+ (a dashboard layout, a dismissed nag). This is eager and intrinsic to the row.
+
+**The unification (do this first, it's the whole Finding-1 fix):** one shape, `page.meta.seo`, used by
+CMS pages and blog articles alike:
+
+```
+page.meta.seo = { title, description, canonical, robots: {index, follow}, og: {…}, twitter: {…} }
+```
+
+The CMS's existing `meta.description` migrates into `meta.seo.description`; the blog's
+`meta.seo.seo_title` / `seo_description` lose their stutter. **Write a migration for the existing
+rows** — a reader that tolerates both shapes forever is how you end up with both shapes forever.
+
+### 3a. What about pages that aren't `page` rows?
+
+Module routes (`/docs`, a blog archive, a module's own screen) have no `page` row, so they have no
+`page.meta`. **They don't get one.** A module contributing its own head entries is the *normal* case,
+not a special case — it knows its title and description better than a database row would, and it says
+so through the same registry as everyone else (§1). Resisting a route-keyed `seo` table here is what
+keeps this module small.
+
+---
+
+## 4. Structured data: a typed registry, not a God plugin
+
+RankMath ships a schema generator that knows about WooCommerce products, EDD downloads, recipes, and
+so on — because a WP plugin has no way to *ask* another plugin what its content is.
+
+Tiger inverts it: **the module that owns a content type owns its schema.**
+
+- TigerSEO provides the registry, the JSON-LD serializer, `@graph` assembly, escaping, and the
+ `WebSite` / `Organization` / `BreadcrumbList` basics that come from platform data it already has
+ (site name from `config`, the org, the menu tree).
+- **`blog` contributes `Article`/`BlogPosting`** from its own fields. `media` contributes
+ `ImageObject`. A future commerce module contributes `Product`. TigerSEO never learns their shapes.
+- The registry is the **shortcode-registry pattern** generalized — the same move THEMES.md §3 makes
+ for blocks. Consistency with an existing seam beats a novel one.
+
+If TigerSEO is ever tempted to `if ($type === 'product')`, the design has failed.
+
+---
+
+## 5. `robots.txt` and `sitemap.xml` are **routes**, never files
+
+Both are served by controllers. **TigerSEO must never write a file into the docroot.**
+
+Two independent reasons, and either alone is sufficient:
+
+1. **The module rule** (ARCHITECTURE §6, core): a module never touches infrastructure — no web-server
+ config, no filesystem outside its own dir. Writing to the docroot breaks 1-click install and the
+ ownership boundary. RankMath edits `.htaccess`; we don't get to.
+2. **The cPanel landmine** ([[cpanel-hosting-constraint]]): `public/.htaccess` serves real files first
+ (`RewriteCond %{REQUEST_FILENAME} -s [OR] -l [OR] -d` → `[L]`). **A physical `robots.txt` in the
+ docroot silently shadows the route** and no amount of PHP will run. Neither file exists today, so
+ the routes are free — and this is exactly the bug that costs a day when someone "helpfully" drops a
+ static one in.
+
+Both are **declared route overrides** (`Tiger_Routing_Overrides`), not `addRoute` calls (ROUTING.md
+§2). Verify early that a dotted path (`robots.txt`) survives the prefix matcher — the plugin bails
+when a real controller claims the URL, and there's no controller named `robots.txt`, so it should fall
+through cleanly. *(The `.htaccess` dotfile block is leading-dot only, so it doesn't interfere.)*
+
+### 5a. Sitemaps without cron
+
+cPanel can't be assumed to have cron ([[cpanel-hosting-constraint]]), so a nightly rebuild is out.
+**Generate on demand behind a fingerprint-invalidated build cache** — the TigerDocs pattern: cheap
+fingerprint (max `updated_at` + row count, per org + locale) → miss → rebuild → cache inside the app
+root (`var/`, never the docroot, per §5). Self-healing, no DB table, fleet-safe, no cron.
+
+At size: a **sitemap index** plus paginated children (50k URL / 50MB caps are the spec). Don't design
+for a million URLs on day one, but don't design a shape that can't get there — the index is cheap now
+and a rewrite later.
+
+---
+
+## 6. hreflang is nearly free — take the win
+
+Tiger already resolves `/es/anything` on every route, persists the choice, and stores one `page` row
+per language (FEATURES: semantic locale URLs, language-only locales). So the sibling set for a URL is
+**already a query Tiger can answer**, which means `hreflang` + `x-default` is mostly serialization.
+
+This is worth calling out because it's a genuine platform dividend: in WordPress, multilingual SEO
+means Polylang/WPML plus an SEO addon, and it's a perennial mess. Tiger gets it because locales were
+in the routing model from the start. **Ship it in the first phase**, not as an advanced feature — it's
+one of the few places the architecture visibly pays the user back.
+
+---
+
+## 7. Multi-tenant, because `config` already is
+
+SEO settings are `config` rows, so per-org SEO falls out with no extra design — org A and org B on one
+install get their own titles, robots policy, and verification tags. Same mechanism as per-org theming.
+Sitemaps and robots are already org-scoped because the `page` store is.
+
+Nothing to build here. It's a consequence, and it's the kind of thing a single-tenant CMS can't
+retrofit.
+
+---
+
+## 8. Content analysis (the green-light score) — deferred, and honestly
+
+**Out of v1. Scoped, not built.**
+
+It's RankMath's headline and the reason people pick it over Yoast — and it's the biggest lift here
+(keyword analysis, readability, a pile of editor JS) while being the *least* architectural: it bolts
+onto the editor and changes nothing underneath. So it's the correct thing to defer: the foundation
+doesn't move to accommodate it later.
+
+When it's built, build it with a spine: the score is **advisory**, it never blocks publishing, and it
+never claims to know what Google thinks. A checklist that turns writers into keyword-stuffers is worse
+than no checklist. If we can't ship one that's honest about being a heuristic, not shipping is a
+defensible product stance.
+
+---
+
+## 9. Phasing
+
+1. **Head adoption + unification (the gate).** Core's layouts move to `headTitle`/`headMeta`/
+ `headLink`. Unify to `page.meta.seo` + migrate existing rows. Render **description, canonical,
+ robots** — i.e. make the data Tiger already collects actually work. *(Everything else depends on
+ this; nothing else is worth doing before it.)*
+2. **Social.** OG + Twitter cards, `og_image_id` → the `media` row (URL + real dimensions). Finally
+ renders a field the blog editor has been collecting all along.
+3. **hreflang** (§6) — cheap, high-signal, mostly free.
+4. **robots.txt + sitemap.xml** as routes, with the build cache (§5).
+5. **Structured data** — the registry + `WebSite`/`Organization`/`BreadcrumbList`; `blog` contributes
+ `Article` (§4).
+6. **Redirects UI + 404 monitor** — a UI over the **existing** `page_redirect`, plus a `seo_404` table
+ (the module's only new table) and "promote a 404 to a redirect."
+7. **Later:** content analysis (§8), IndexNow, Search Console, image SEO automation, news/video
+ sitemaps.
+
+---
+
+## 10. Rejected alternatives (so we don't relitigate them)
+
+| Rejected | Why | Chosen instead |
+|---|---|---|
+| TigerSEO owns the head registry | every theme would depend on an SEO module to render ``; a coupling that never comes back out | **core** adopts TigerZF's `HeadMeta`/`HeadTitle`/`HeadLink`; TigerSEO just appends (§1) |
+| Build a bespoke head/meta abstraction | ZF1 already ships placeholder containers with `set`/`append`/`prepend` and ordering — we'd be reinventing what's in the box, badly | adopt `Zend_View_Helper_Head*` (§0 Finding 2) |
+| A dedicated `seo` table | a join on every page render for data that's 1:1 with the row, loses free versioning via `page_version` and the org cascade | `page.meta.seo` (§3) |
+| SEO settings in a new table / an options blob | the `wp_options` mistake RankMath is the poster child for | `config` rows — live-override, per-org (§2, §7) |
+| Keep both `meta.description` and `meta.seo.description` and read either | a tolerant reader is how you keep two shapes forever | **unify + migrate the rows** (§3) |
+| A route-keyed `seo` table for non-page URLs | a table to hold what a module already knows about itself | modules contribute their own head entries (§3a) |
+| TigerSEO knows each content type's schema | RankMath's God-plugin shape; can't extend without editing us | **typed registry** — content types describe themselves (§4) |
+| Writing `robots.txt` / `sitemap.xml` into the docroot | modules never touch infra; **and** cPanel's real-file-first rule makes a physical file silently shadow the route | **routes** + a `var/` build cache (§5) |
+| Editing `.htaccess` (RankMath does) | never, for any module, for any reason | PHP-layer route overrides (ROUTING.md) |
+| Cron-generated sitemaps | cPanel has no guaranteed cron | on-demand + fingerprint-invalidated cache (§5a) |
+| Content analysis in v1 | biggest lift, least architectural, bolts on cleanly later | deferred and scoped (§8) |
+| Phone-home / account-gated features / in-admin upsell | it's free and BSD; and we hold that line for themes already | none of it, ever |
+
+---
+
+*This document records decisions and their rationale. If you change a decision, update the relevant
+section here in the same change — the "why" is the most valuable and most perishable part.*
diff --git a/modules/seo/Bootstrap.php b/modules/seo/Bootstrap.php
new file mode 100644
index 0000000..b070363
--- /dev/null
+++ b/modules/seo/Bootstrap.php
@@ -0,0 +1,17 @@
+registerPlugin(new Seo_Plugin_Head(), 90);
+ }
+}
diff --git a/modules/seo/FEATURES.md b/modules/seo/FEATURES.md
new file mode 100644
index 0000000..d9066ef
--- /dev/null
+++ b/modules/seo/FEATURES.md
@@ -0,0 +1,108 @@
+# TigerSEO — Features
+
+A factual inventory of what TigerSEO will do. For the *why* see [ARCHITECTURE.md](ARCHITECTURE.md);
+for the conventions to build it see [AGENTS.md](AGENTS.md).
+
+> **Status: design-of-record (proposed, not built).** Nothing below ships yet — every item is a
+> target, not a claim. As things land, move them into plain present tense and keep this file an
+> inventory rather than marketing. The **Deliberately absent** section is as load-bearing as the rest.
+
+Free, **BSD-3-Clause**, public. No pro tier, no account, no phone-home, no upsell.
+
+## The ``
+
+- **A real registry.** Title, meta, and link tags are contributed through `Zend_View_Helper_HeadTitle`
+ / `HeadMeta` / `HeadLink` — the helpers TigerZF has always shipped and Tiger never used. Themes,
+ modules, the page author, and TigerSEO all contribute; last authority wins, deterministically.
+ Uninstall TigerSEO and the head still renders, just with less in it.
+- **Title templates** with variables (`%title% — %sitename%`), per content type, org-scoped,
+ overridable per page.
+- **Meta description**, per page — *rendered*, which today it is not.
+- **Canonical** — explicit per page, else self-referencing.
+- **Meta robots** — `index`/`noindex`, `follow`/`nofollow`, plus `noarchive`/`nosnippet`/`max-*`
+ controls, per page and by default per content type.
+- **`head_html` still works.** The raw escape hatch stays for verification tags and one-off vendor
+ snippets. It stops being the only road; it doesn't stop being a road.
+
+## Social
+
+- **Open Graph** — `og:title`, `og:description`, `og:image`, `og:type`, `og:url`, `og:site_name`,
+ `article:published_time`/`modified_time`/`author`.
+- **Twitter cards** — `summary` / `summary_large_image`, falling back to OG rather than duplicating it.
+- **`og_image_id` finally renders.** The blog editor already collects it; TigerSEO resolves it through
+ the `media` row for a real URL and real dimensions (`og:image:width`/`height`), with a per-org
+ fallback image.
+
+## International
+
+- **hreflang + `x-default`**, generated from the locale siblings Tiger already stores (one `page` row
+ per language) and the locale-prefix routes it already resolves. Ships in an early phase — it's
+ mostly serialization, and it's the sort of thing that costs a WordPress site two plugins and an
+ argument.
+
+## Structured data (JSON-LD)
+
+- **A typed registry.** TigerSEO supplies the serializer, `@graph` assembly, and the platform-level
+ types it can derive from data it already has: `WebSite`, `Organization`, `BreadcrumbList` (from the
+ menu tree).
+- **Content types describe themselves.** The `blog` module contributes `Article`/`BlogPosting`;
+ `media` contributes `ImageObject`; a future commerce module contributes `Product`. TigerSEO never
+ learns anyone else's shape and never needs editing to support a new one.
+
+## robots.txt & sitemaps
+
+- **`/robots.txt` is a route**, generated from config + per-org rules, editable in the admin. Never a
+ file in the docroot — on cPanel a real file silently shadows the route (ARCHITECTURE §5).
+- **`/sitemap.xml` is a route** — a sitemap index plus paginated children, org- and locale-scoped,
+ with `lastmod` from the page rows.
+- **No cron required.** Generated on demand behind a fingerprint-invalidated build cache in `var/`
+ (the TigerDocs pattern) — self-healing, fleet-safe, and it works on a shared host with no shell.
+
+## Redirects & 404s
+
+- **A UI over the redirects Tiger already has.** `page_redirect` is built, org-scoped, locale-aware,
+ 301/302, and already auto-written when a slug changes. This is a management screen, not an engine —
+ list, search, add, edit, soft-delete.
+- **404 monitor** — a `seo_404` table (the module's only new table) logging misses with referrer and
+ hit count, plus one-click **promote a 404 to a redirect**.
+
+## Multi-tenant
+
+- **Every setting is a `config` row**, so all of it is per-org and live-editable with no deploy —
+ titles, robots policy, verification tags, default social image. Sitemaps and robots are org-scoped
+ because the `page` store already is. Nothing extra to build; it's a consequence of the platform.
+
+## Admin
+
+- **A per-page SEO panel** in the CMS and blog editors — title, description, canonical, robots,
+ social, with a **live Google/social preview** and honest length guidance (pixel-width, not a
+ character count that lies).
+- **Settings screens** registered into the shared Settings tree, built per ADMIN.md — no bespoke shell.
+
+## Deliberately absent
+
+Their absence is a decision, not an oversight (ARCHITECTURE §10):
+
+- **No `.htaccess` editing.** No module touches web-server config, for any reason.
+- **No files written into the docroot.** robots and sitemap are routes.
+- **No options blob.** Settings are `config` rows, not a serialized autoloaded lump.
+- **No `seo` table for page metadata.** It lives in `page.meta.seo` — versioned and org-cascaded free.
+- **No content-type knowledge.** TigerSEO never contains `if ($type === 'product')`.
+- **No phone-home, no account, no upsell, no pro tier.**
+- **No SEO score in v1** (see below).
+
+## Planned, not committed
+
+- **Content analysis / the green-light score** — deferred out of v1 (ARCHITECTURE §8). Biggest lift,
+ least architectural, bolts on later without moving the foundation. When built: advisory only, never
+ blocks publishing, never pretends to know what Google thinks.
+- **IndexNow / instant indexing**, Search Console integration, image-SEO automation (auto `alt` from
+ `media`), news/video sitemaps.
+
+## Blocked on work elsewhere
+
+- **tiger-core** — the PUMA layouts must adopt `headTitle`/`headMeta`/`headLink` instead of hardcoding
+ `` and echoing `pageHead`. **This gates everything**, and it's platform hygiene that stands on
+ its own merits — it is *not* an SEO feature and shouldn't be argued for as one.
+- **The `page.meta.seo` unification + row migration** — touches `modules/cms` and `modules/blog`, both
+ first-party, both in tiger-core.
diff --git a/modules/seo/README.md b/modules/seo/README.md
new file mode 100644
index 0000000..73b5b03
--- /dev/null
+++ b/modules/seo/README.md
@@ -0,0 +1,64 @@
+# TigerSEO
+
+SEO for [Tiger](https://github.com/WebTigers/Tiger) — the ``, structured data, sitemaps,
+robots, and redirects — done as platform plumbing rather than as a plugin that pastes strings into
+your template.
+
+> **Status: design-of-record (proposed, not built).** No code yet. These four documents are the scope:
+> [ARCHITECTURE.md](ARCHITECTURE.md) (the *why* + rejected alternatives), [FEATURES.md](FEATURES.md)
+> (what it will do, and what it deliberately won't), [AGENTS.md](AGENTS.md) (conventions for whoever
+> builds it — human or AI).
+
+**Free, BSD-3-Clause, public** — like TigerDocs and TigerShield. SEO is table stakes for anything with
+a CMS; it isn't a thing you sell back to someone who already installed the platform.
+
+## The pitch
+
+Everything RankMath does that's worth doing, minus the parts that made RankMath what it is: no options
+blob, no ~2,000 hooks, no phone-home, no in-admin upsell, no editing your `.htaccess` or writing files
+into your docroot.
+
+The short version of the design:
+
+- **The `` is a registry, not a string.** Modules, themes, and TigerSEO all contribute typed
+ entries and the last authority wins deterministically — using `Zend_View_Helper_HeadMeta` /
+ `HeadTitle` / `HeadLink`, which **already ship in TigerZF and which Tiger has never used** (§1).
+- **One SEO shape**, stored in `page.meta.seo`, versioned for free by `page_version`, loaded with the
+ page row it belongs to. No new table, no join on every render (§3).
+- **Structured data is a typed registry** — the `blog` module says what an Article is; TigerSEO never
+ learns about anyone's content type (§4).
+- **Consume what Tiger already has:** `page_redirect` (301s, org-scoped, auto-written on slug change)
+ *is* a redirections engine; `media.alt_text` is image SEO; locale-prefix routes make **hreflang**
+ nearly free; the CMS shortcode registry and TigerDocs' fingerprint-invalidated build cache are the
+ models for schema and sitemaps.
+- **Multi-tenant from line one** — SEO settings are `config` rows, so they're per-org and live-editable
+ with no deploy. A single-tenant CMS structurally cannot do this.
+
+## What exists today (the honest starting point)
+
+Tiger's public `` is `charset`, `viewport`, and ``. That's all of it.
+
+Meanwhile the CMS page editor *collects* a meta description, and the `blog` module *collects*
+`seo_title`, `seo_description`, `og_image_id`, and `canonical` — and **none of it is ever rendered.**
+An author can set a canonical URL on an article today, save it, and the tag never appears. The only
+thing that reaches the `` is `head_html`: a raw textarea, emitted unescaped into the theme's
+`pageHead` slot. That's the "paste your meta tags here" hatch RankMath exists to replace.
+
+So TigerSEO's first job is not features. It's rendering data Tiger already asks people to type.
+
+## Layout (planned)
+
+| Path | What |
+|---|---|
+| `module.json` | manifest — slug `seo`, `type: plugin` |
+| `library/` | `Seo_Head`, `Seo_Schema` registry, `Seo_Sitemap`, resolvers |
+| `controllers/` | `robots.txt` + `sitemap.xml` routes; the admin screens |
+| `services/` | `Seo_Service_Settings`, `Seo_Service_Redirect` — the `/api` surface |
+| `views/` | settings, redirects, the per-page SEO panel partial |
+| `configs/` | `acl.ini`, `routes.ini`, `dependency.ini` |
+| `migrations/` | `seo_404` (the 404 monitor) — the only new table, and only in a later phase |
+| `languages/` | `seo.*` keys |
+
+---
+
+Built by WebTigers. Licensed BSD-3-Clause. Tiger™ and WebTigers™ are trademarks of WebTigers.
diff --git a/modules/seo/plugins/Head.php b/modules/seo/plugins/Head.php
new file mode 100644
index 0000000..458d8de
--- /dev/null
+++ b/modules/seo/plugins/Head.php
@@ -0,0 +1,32 @@
+getParam('cms_page_id', '');
+ if ($pageId === '') {
+ return; // not a CMS page dispatch
+ }
+ try {
+ $page = (new Tiger_Model_Page())->findById($pageId);
+ if ($page) {
+ Seo_Service_Head::forRow($page, $request);
+ }
+ } catch (Throwable $e) {
+ // fail-open — a broken SEO lookup must never take down a page render
+ }
+ }
+}
diff --git a/modules/seo/services/Head.php b/modules/seo/services/Head.php
new file mode 100644
index 0000000..ee6cf19
--- /dev/null
+++ b/modules/seo/services/Head.php
@@ -0,0 +1,112 @@
+: it never renders markup itself and never
+ * touches the theme — it appends typed entries and the layout prints them. Reached two ways for the two
+ * content paths: Seo_Plugin_Head for CMS pages (dispatched via PageDispatch → cms_page_id), and a direct,
+ * class_exists-guarded call from the blog article controller (which has its own dispatch). Phase 1 emits
+ * title / description / robots / canonical; Open Graph + Twitter are Phase 2. Internal (NOT a /api service).
+ */
+class Seo_Service_Head
+{
+ /**
+ * Populate the head containers from a page row's SEO metadata. Fail-soft — SEO never breaks a render.
+ *
+ * @param mixed $page a `page`/`post` row (Zend_Db_Table_Row) with a JSON `meta`
+ * @param Zend_Controller_Request_Abstract $request the current request (for a self-referencing canonical)
+ * @param array $overrides caller fallbacks that fill BLANKS only (e.g. a blog
+ * article's excerpt → description); an author-set value wins
+ * @return void
+ */
+ public static function forRow($page, Zend_Controller_Request_Abstract $request = null, array $overrides = [])
+ {
+ if (!$page) {
+ return;
+ }
+ $meta = self::_meta($page);
+ $seo = (isset($meta['seo']) && is_array($meta['seo'])) ? $meta['seo'] : [];
+ foreach ($overrides as $k => $v) {
+ if ($v !== null && $v !== '' && empty($seo[$k])) { $seo[$k] = $v; }
+ }
+ $view = self::_view();
+ if (!$view) {
+ return;
+ }
+
+ // Title — an author-set SEO title overrides the page title the layout would otherwise seed.
+ $title = trim((string) ($seo['title'] ?? ''));
+ if ($title !== '') {
+ $view->headTitle()->set($title);
+ }
+
+ // Meta description.
+ $desc = trim((string) ($seo['description'] ?? ''));
+ if ($desc !== '') {
+ $view->headMeta()->setName('description', $desc);
+ }
+
+ // Robots — the absence of the tag means index,follow; emit a directive ONLY when restricted.
+ $robots = self::_robots($seo);
+ if ($robots !== '') {
+ $view->headMeta()->setName('robots', $robots);
+ }
+
+ // Canonical — explicit if the author set one, else self-referencing (clean path, no query).
+ $canonical = trim((string) ($seo['canonical'] ?? ''));
+ if ($canonical === '' && $request) {
+ $canonical = self::_currentUrl($request);
+ }
+ if ($canonical !== '') {
+ $view->headLink(['rel' => 'canonical', 'href' => $canonical]);
+ }
+ }
+
+ // -- internals -----------------------------------------------------------------------------------
+
+ /** Decode a row's JSON `meta` to an array (tolerates an already-decoded array). */
+ private static function _meta($page)
+ {
+ $raw = $page->meta ?? null;
+ if (is_array($raw)) {
+ return $raw;
+ }
+ $decoded = $raw ? json_decode((string) $raw, true) : null;
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /** Build the robots content from `seo.robots.{index,follow}`; '' means the default (index,follow). */
+ private static function _robots(array $seo)
+ {
+ $r = (isset($seo['robots']) && is_array($seo['robots'])) ? $seo['robots'] : [];
+ $parts = [];
+ if (array_key_exists('index', $r) && !$r['index']) { $parts[] = 'noindex'; }
+ if (array_key_exists('follow', $r) && !$r['follow']) { $parts[] = 'nofollow'; }
+ return implode(', ', $parts);
+ }
+
+ /** The current request's absolute URL, path only (a stable self-referencing canonical). */
+ private static function _currentUrl(Zend_Controller_Request_Abstract $request)
+ {
+ if (!method_exists($request, 'getScheme')) {
+ return '';
+ }
+ $path = (string) parse_url((string) $request->getRequestUri(), PHP_URL_PATH);
+ return $request->getScheme() . '://' . $request->getHttpHost() . ($path !== '' ? $path : '/');
+ }
+
+ /** A Zend_View to reach the head helpers. Any instance shares the process-wide placeholder 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();
+ }
+}
diff --git a/themes/puma/layouts/scripts/layout.phtml b/themes/puma/layouts/scripts/layout.phtml
index 78898d3..167e994 100644
--- a/themes/puma/layouts/scripts/layout.phtml
+++ b/themes/puma/layouts/scripts/layout.phtml
@@ -22,7 +22,17 @@ $_lang = defined('LANG') ? LANG : 'en';
- = $this->escape($this->title ?? ($this->siteName ?? 'Tiger')) ?>
+title (or the site name) so a always renders here —
+ with or without an SEO module installed. */
+ if (!count($this->headTitle())) {
+ $this->headTitle($this->title ?? ($this->siteName ?? 'Tiger'));
+ }
+?>
+ = $this->headTitle() ?>
+ = $this->headMeta() ?>
@@ -39,7 +49,8 @@ $_lang = defined('LANG') ? LANG : 'en';
= $this->codeInject('head') ?>
-= $this->pageHead ?? '' ?>
+ = $this->headLink() ?>
+= $this->pageHead ?? '' ?>