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 <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); 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 @@ +<?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' => [], +]; 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 + `<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). 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 | +|---|---| +| `<meta name="description">` | **never emitted** — the public layout is `charset`, `viewport`, `<title>`, 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 `<title>` 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 `<head>` 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 `<title>` and `<meta>` 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 `<title>` 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 `<head>` — 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 `<title>`; 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 @@ +<?php +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +/** + * Seo_Bootstrap — TigerSEO's module bootstrap. Phase 1 registers the head plugin that contributes a CMS + * page's SEO metadata to the head registry (see ARCHITECTURE.md / FEATURES.md in this dir for the full + * design). It adds NO custom head registry of its own — it appends to TigerZF's headTitle/headMeta/ + * headLink, which the core layout now renders. Uninstall the module and the head still renders, with less. + */ +class Seo_Bootstrap extends Zend_Application_Module_Bootstrap +{ + /** Register the head plugin — high stackIndex so it runs after core routing plugins (PageDispatch). */ + protected function _initSeoHead() + { + Zend_Controller_Front::getInstance()->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 `<head>` + +- **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 + `<title>` 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 `<head>`, 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 `<head>` 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 `<head>` is `charset`, `viewport`, and `<title>`. 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 `<head>` 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 @@ +<?php +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +/** + * Seo_Plugin_Head — populates the head registry for CMS pages, which dispatch through PageDispatch + * (it resolves the slug → a `page` row and sets `cms_page_id`). Runs at preDispatch (after routing), so + * the head containers are filled before the view + layout render. Fail-open: SEO never breaks a request. + * + * Blog articles render via their own controller (no `cms_page_id`) and call Seo_Service_Head directly. + */ +class Seo_Plugin_Head extends Zend_Controller_Plugin_Abstract +{ + /** + * @param Zend_Controller_Request_Abstract $request + * @return void + */ + public function preDispatch(Zend_Controller_Request_Abstract $request) + { + $pageId = (string) $request->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 @@ +<?php +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +/** + * Seo_Service_Head — the resolver that maps a page row's `meta.seo` onto the shared head registry + * (TigerZF's headTitle/headMeta/headLink placeholder containers), which the layout renders. + * + * The single seam TigerSEO uses to contribute to the <head>: 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'; <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title><?= $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() ?> <!-- No-FOUC theme resolver: MUST run before the stylesheets below paint. --> <script src="<?= $this->asset($this->themeAssets . '/js/theme-init.js') ?>"></script> @@ -39,7 +49,8 @@ $_lang = defined('LANG') ? LANG : 'en'; <!-- Override playground — loads last so it wins the cascade. See themes/puma/assets/custom.css. --> <link rel="stylesheet" href="<?= $this->asset($this->themeAssets . '/custom.css') ?>"> <?= $this->codeInject('head') ?> -<?= $this->pageHead ?? '' ?><?php /* per-page head from the CMS page meta (admin-authored) */ ?> + <?= $this->headLink() ?><?php /* SEO/link registry: rel=canonical, hreflang, … (contributed, e.g. by TigerSEO) */ ?> +<?= $this->pageHead ?? '' ?><?php /* per-page raw head from the CMS page meta (admin-authored) — the escape hatch */ ?> </head> <body> <?= $this->render('_partials/public-header.phtml') ?>