docs(32): set SEO code layout, resolve phase split, confirm D-32
SEO moves to sm-seo-plugin and sm-sitemap-plugin on three generic framework seams (form extensions with field placement, record events, signpost content descriptors), following the mpseo pattern. Phase 32 is split into 32/33/34.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Phase 32: SEO foundation - Context
|
||||
|
||||
**Gathered:** 2026-10-24
|
||||
**Status:** Ready for planning. One open question for Jakub: the phase split, see "Open question" below.
|
||||
**Status:** Ready for planning. On 2026-10-25 Jakub resolved the phase split (Q-01), confirmed D-32 and set the code layout (section 0, D-90 to D-97).
|
||||
**Participant:** Dawid (SEO specialist, delegated by Jakub for the SEO discussion). The discussion was in Polish; every decision below is recorded in English.
|
||||
|
||||
<domain>
|
||||
@@ -10,7 +10,7 @@
|
||||
Search-engine optimisation built into SummerCMS, meant to be better suited to SEO than WinterCMS, for every application on the framework.
|
||||
|
||||
SummerCMS is headless: Go serves the admin and the JSON APIs, and Nuxt layers or apps render the public HTML. SEO is therefore split in two:
|
||||
- **Backend (Go framework and shared plugins):** owns the SEO data, the rules and the admin. It serves the effective SEO of a URL to the frontend, along with robots.txt, the sitemaps, the redirect rules and the verification files. Every operation is reachable through MCP.
|
||||
- **Backend (shared plugins, on generic framework seams):** owns the SEO data, the rules and the admin. It serves the effective SEO of a URL to the frontend, along with robots.txt, the sitemaps, the redirect rules and the verification files. Every operation is reachable through MCP. The SEO code lives in `sm-seo-plugin` (Golem15.SEO) and `sm-sitemap-plugin` (Golem15.Sitemap); the framework only gains generic seams that name nothing SEO-specific (D-90).
|
||||
- **Nuxt layers:** render the data into `<head>`, JSON-LD, breadcrumbs, pagination and headings, enforce redirects and noindex on their routes, and guarantee server rendering.
|
||||
|
||||
The phase covers:
|
||||
@@ -42,27 +42,61 @@ Out of scope: e-commerce structured data and product or category shop pages (no
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Open question for Jakub: phase split
|
||||
- **Q-01:** Dawid said the phase split is not his decision but Jakub's. The scope above is several weeks of work. Claude's recommendation is three consecutive phases. Each delivers a working slice that can ship to Płytarium, and every decision in this document applies to all three:
|
||||
### Phase split (resolved)
|
||||
- **Q-01 (resolved by Jakub, 2026-10-25): three consecutive phases.** Dawid left the split to Jakub. Each phase delivers a working slice that can ship to Płytarium, and every decision in this document applies to all three:
|
||||
- **32 Foundation:**
|
||||
- data model, SEO tab, global settings;
|
||||
- the three framework seams (D-91 to D-93), with the field placement rules of D-91;
|
||||
- `sm-seo-plugin` core: data model, SEO tab, global settings;
|
||||
- meta, canonical, robots;
|
||||
- redirects, URL normalisation, strict URLs;
|
||||
- robots.txt, MCP tools, history.
|
||||
- **33 Output:**
|
||||
- XML and HTML sitemaps, page registry;
|
||||
- `sm-sitemap-plugin` rewrite (D-32): XML and HTML sitemaps;
|
||||
- page registry;
|
||||
- JSON-LD, pagination, hreflang;
|
||||
- headings, breadcrumbs;
|
||||
- SSR rules in the layers.
|
||||
- the Nuxt SEO layer and the SSR rules in the layers.
|
||||
- **34 Audit and content:**
|
||||
- crawler and issue report;
|
||||
- internal linking and auto-links;
|
||||
- media (alt, file names, formats).
|
||||
|
||||
The alternative is one phase with many plans. Jakub decides before `/msd-plan-phase`; the planner must not split or merge phases on its own.
|
||||
ROADMAP.md still lists one Phase 32 with the full scope. Before `/msd-plan-phase 32`, narrow Phase 32 and add Phases 33 and 34 through `/msd-phase`, with their g15office tasks. The planner must not split or merge phases on its own.
|
||||
|
||||
### 0. Code layout (Jakub, 2026-10-25)
|
||||
- **D-90:** **The framework carries no SEO code.** SEO lives in two shared plugins: a new `sm-seo-plugin` (Golem15.SEO) and an updated `sm-sitemap-plugin` (Golem15.Sitemap). The framework gets only the generic seams those plugins need (D-91 to D-93); none of them names SEO, and each is useful to other plugins. The pattern is the PixelPixel `mpseo` WinterCMS plugin (see Reference implementations):
|
||||
- content plugins did not import anything from it; they registered a content type (model, controller, a mapper for default title/description/image, a URL resolver, an `is_published` check, the tab position) through the `seoinjection.extend` event;
|
||||
- the SEO plugin owned one `entries` table keyed by source type and id plus the URL, injected the SEO tab into the registered controllers through `extendFormFields`, deleted the entry with the record, kept per-type defaults with placeholders in its settings (with an inject switch per type), generated entries in a job, had a Google result preview and revisions, and resolved SEO by URL on the frontend.
|
||||
|
||||
SummerCMS keeps that division and replaces the event with D-91 to D-93. — **Reversibility:** costly — the seams become framework API, and moving SEO back into the framework later would mean migrating the plugin's tables.
|
||||
- **D-91:** **Cross-plugin admin form extensions, with real field placement.** `pact.AdminActionExtension` (controller ID plus record actions; its comment already leaves room for more kinds) gains form fields and tabs. An extension targets a controller ID, adds fields and tabs to its form, and loads and saves their values itself through `Load` and `Save` hooks that run inside the record's save transaction. The owner's model and tables are untouched.
|
||||
|
||||
Placement must work properly. In WinterCMS, adding field C at the end of A, B was easy, but placing it between them (A, C, B) meant fighting `extendFormFields`. Here:
|
||||
- every field and tab in a compiled form has a stable name: the field key from `fields.yaml`, the tab key;
|
||||
- an extension places each field with `Before` or `After` an anchor field, inside an existing tab, a new tab, or a zone (outside the tabs, primary tabs, secondary tabs); with no anchor the field goes to the end of its tab, as in WinterCMS;
|
||||
- a new tab is placed the same way, `Before` or `After` another tab;
|
||||
- an extension may hide an owner's field, for example a plugin's own meta-title field that the SEO tab replaces (`mpseo` removed `viewBag[meta_title]` the same way);
|
||||
- the result is deterministic: extensions apply in plugin order (`party`, by `Requires()`); several extensions anchored at the same point keep that order; an anchor may name a field added by another extension only if that plugin is in `Requires()`;
|
||||
- an unknown anchor, a duplicate name or an anchor cycle stops the boot with a clear error, like the existing `AdminActionExtension` checks;
|
||||
- cabana compiles the final order at boot; the SPA renders the compiled form as it is and never reorders it; field `span` (left, right, full) keeps working next to inserted fields;
|
||||
- top-level fields and tabs only; fields inside repeaters and nested forms are out of scope for now.
|
||||
|
||||
Tests cover at least: C after A gives A, C, B; C before the first field; a new tab between two existing tabs; two plugins anchored at the same field; hiding a field; each boot error. — **Reversibility:** one-way — the extension struct and the anchor rules become a published framework API.
|
||||
- **D-92:** **Record events from cabana.** Other plugins can subscribe to "record saved" (created or updated) and "record deleted" for a controller or model type. The event carries the record id, the locale and the changed attributes with old and new values (so a slug or parent change is visible), and fires after commit. Admin, MCP and imports all go through cabana and emit it; plugin code that writes records directly uses a cabana helper to emit the same event. Today only GORM hooks on the owner's model exist, which another plugin cannot use. The SEO plugin uses the events for the automatic 301 (D-18) and to delete SEO entries with their record. — **Reversibility:** costly — subscribers in shared plugins depend on the event shape.
|
||||
- **D-93:** **Content descriptors in signpost.** `pact.MenuItemType` already has URL patterns, `Resolve` and locale alternates. It gains optional functions that describe the type's content: list the records (for sitemaps and the registry), `lastmod`, whether a record is published, and default title, description and image (the `mapper` of `mpseo`). Content plugins (journal, FAQ, future ones) only register types in signpost, as they already do for menus and the sitemap, and never import `sm-seo-plugin` or `sm-sitemap-plugin`. — **Reversibility:** costly — every content plugin implements these functions.
|
||||
- **D-94:** **`sm-seo-plugin` (Golem15.SEO)**, a new repo mounted like the other core plugins:
|
||||
- models: the SEO entry (type, id, locale, URL, the SEO tab's values, `data` jsonb), entry revisions (history and undo), settings (sections 1 and 4, with a tab per content type: templates with placeholders and an inject switch, as in `mpseo`), URL rules, redirects, the 404 log, robots.txt, the page registry (D-33);
|
||||
- uses D-91 to add the SEO tab to every registered type's controller, D-92 for the automatic 301 and cleanup, D-93 for defaults and URL ownership;
|
||||
- a job that creates missing entries (the `mpseo` GenerateSeoEntriesJob), run through `conga`;
|
||||
- the frontend API: effective SEO by URL, redirects, robots.txt, llms.txt, verification files, JSON-LD `@graph`, breadcrumbs;
|
||||
- admin widgets: the result preview, the placeholder list, revisions;
|
||||
- MCP through `pier` from its admin routes, plus CSV import/export and the issue report; the crawler (Phase 34) lives here too.
|
||||
- **D-95:** **`sm-sitemap-plugin` (Golem15.Sitemap)** produces the sitemaps of D-30, D-31 and D-34 from signpost (D-93). It knows nothing about SEO: it defines its own small filter interface (exclude a URL, noindex, `lastmod` override) and looks it up with `app.Lookup`. `sm-seo-plugin` implements it and requires Sitemap (`Requires()`); Sitemap works without SEO.
|
||||
- **D-96:** **Content plugins keep only their own fixes.** Journal gets the translated-slug fix (D-58), the H1 → H2 conversion (D-50), its schema types and the D-93 descriptors. It does not depend on the SEO plugin.
|
||||
- **D-97:** **The Nuxt side is one new shared layer** (`vue-seo-layer`, name to confirm): `<head>` from the effective-SEO API, redirect middleware, JSON-LD, breadcrumbs, the SSR build guard (D-70). Journal, menu and FAQ layers and the apps extend it.
|
||||
|
||||
### 1. SEO data model
|
||||
- **D-01:** **Per-record SEO is a framework mechanism.** A model opts in with one declaration. The framework adds a standard "SEO" tab to its admin form and stores the data in one shared table keyed by (model type, record id, locale). Every plugin, present and future, gets the same tab without its own migrations, and MCP has one place to read and write. cabana and pact have no cross-plugin form extension today (only `pact.HasAdminActionExtensions` for record actions), so this needs a new generic seam. — **Reversibility:** one-way — the shared table and the opt-in contract become a published framework API that every content plugin builds on.
|
||||
- **D-01 (revised by Jakub, 2026-10-25):** **Per-record SEO is owned by `sm-seo-plugin`.** A content type takes part by registering in signpost (D-93); the SEO settings switch the tab on per type, as `mpseo` did. The plugin adds a standard "SEO" tab to the type's admin form through the generic form extension (D-91) and stores the data in its one shared table keyed by (model type, record id, locale). Every plugin, present and future, gets the same tab without its own migrations, and MCP has one place to read and write. Originally recorded as a framework mechanism; moved to the plugin under D-90. — **Reversibility:** one-way — the shared table and the opt-in contract are what every content plugin builds on.
|
||||
- **D-02:** **Per-URL SEO rules** live on a separate screen, with exact URLs and patterns (e.g. `/blog?tag=*`). They cover pages with no record behind them: the home page, Nuxt pages, filter URLs. **Precedence: URL rule > record SEO > global defaults > values derived from content.** — **Reversibility:** costly — the precedence order is visible to editors and MCP clients and is baked into the effective-SEO resolver.
|
||||
- **D-03:** **Global SEO settings** contain:
|
||||
- title templates and defaults: a template such as `%title% | %site%`, a separate home-page template, the default meta description, the default OG image and the site name;
|
||||
@@ -166,8 +200,8 @@ Out of scope: e-commerce structured data and product or category shop pages (no
|
||||
- an image sitemap (`image:image`) with the featured image and the images in the body;
|
||||
- an optional Google News sitemap for chosen categories or types;
|
||||
- optional IndexNow, with a key generated in the admin, pinged on publish, slug change and deletion.
|
||||
- **D-32:** **The new format replaces the PHP-parity mode of `sm-sitemap-plugin`.** The PHP byte parity, priority tiers and single file are removed, and every site moves to the new format, including grzybyfunkcjonalne.pl. This is a **breaking change on a shared core plugin, explicitly chosen by the user**. It needs a new plugin version, retires the PHP parity goldens, and migrates grzyby in the same delivery. Jakub should confirm this when reviewing this document. — **Reversibility:** one-way — sites drop the PHP format and their GSC sitemap submissions change.
|
||||
- **D-33:** **Page registry in the CMS (admin and MCP).** Each entry holds the frontend URL, a page type (page, offer, service, landing), the SEO tab and a modification date. Nuxt pages read their SEO from it, and the sitemaps group by its type. The frontend can report its routes at build time so none are missed. It replaces "CMS pages" from WinterCMS (Winter.Pages was not ported). It is not a content plugin: page content stays in the frontend. — **Reversibility:** costly — the registry's API is consumed by every Nuxt app.
|
||||
- **D-32:** **The new format replaces the PHP-parity mode of `sm-sitemap-plugin`.** The PHP byte parity, priority tiers and single file are removed, and every site moves to the new format, including grzybyfunkcjonalne.pl. This is a **breaking change on a shared core plugin, explicitly chosen by the user**. It needs a new plugin version, retires the PHP parity goldens, and migrates grzyby in the same delivery. **Confirmed by Jakub on 2026-10-25**, with the remark that the split by type is more than small sites need. — **Reversibility:** one-way — sites drop the PHP format and their GSC sitemap submissions change.
|
||||
- **D-33:** **Page registry in the CMS (admin and MCP).** Each entry holds the frontend URL, a page type (page, offer, service, landing), the SEO tab and a modification date. Nuxt pages read their SEO from it, and the sitemaps group by its type. The frontend can report its routes at build time so none are missed. It replaces "CMS pages" from WinterCMS (Winter.Pages was not ported). It is not a content plugin: page content stays in the frontend. The registry lives in `sm-seo-plugin` and registers its page types in signpost, so the sitemaps list them like any other type. — **Reversibility:** costly — the registry's API is consumed by every Nuxt app.
|
||||
- **D-34:** **HTML sitemap:**
|
||||
- It lists everything indexable (no 404s, no redirected URLs, no noindex), the same set as the XML sitemaps.
|
||||
- It is linked from the footer of every page, so also from the home page.
|
||||
@@ -272,7 +306,7 @@ Out of scope: e-commerce structured data and product or category shop pages (no
|
||||
|
||||
### Claude's Discretion
|
||||
- Where redirects execute (D-20a), HTML sitemap grouping and paging (D-34), the Journal translated-slug fix (D-58).
|
||||
- Table, package and module names: a beach-themed framework module name per the repo rules, and whether the redirects, robots and global SEO pieces live in a new shared plugin (`sm-seo-plugin`, for example) or in the framework. The data model and admin seam (D-01) belong in the framework; editorial screens may live in a shared plugin. The research doc decides, and the planner records it.
|
||||
- Table and package names inside the plugins, and the exact Go shape of the three seams (D-91 to D-93). The seams go into the existing `pact`, `cabana` and `signpost` modules; no new framework module is planned.
|
||||
- How the frontend fetches effective SEO: one resolver endpoint per URL, cached and purged through the existing site-cache purge.
|
||||
- Crawler implementation (Go, in-process job via `conga`/River, politeness limits).
|
||||
|
||||
@@ -291,6 +325,7 @@ Out of scope: e-commerce structured data and product or category shop pages (no
|
||||
### Code seams
|
||||
- `modules/pact/capabilities.go`: `HasSettings`/`SettingsItem` (~719-744), `MenuItemType`/`ResolvedMenuItem.Alternates` (~797-856), `AdminSingleton`.
|
||||
- `modules/pact/README.md`: the list of form hooks; `HasAdminActionExtensions` (~149) is the only cross-plugin admin hook today.
|
||||
- `modules/pact/capabilities.go`: `AdminActionExtension`/`HasAdminActionExtensions` (~429-439), the struct D-91 extends.
|
||||
- `modules/cabana/field_ml.go`: `TranslationWriter` and translatable field types (pattern for locale-keyed SEO data).
|
||||
- `modules/cabana/settings.go`: settings form compilation.
|
||||
- `modules/signpost/signpost.go`: the registry (`Activate`, `Resolve`).
|
||||
@@ -302,6 +337,7 @@ Out of scope: e-commerce structured data and product or category shop pages (no
|
||||
- Apps: `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` (robots and sitemap config, swr), `../sm-demo-app/vue-demo-app/` (hand-written noindex on login and similar pages), `../sm-summercmsio-app/vue-summercmsio-app` (nuxt generate).
|
||||
|
||||
### Reference implementations
|
||||
- `/media/nvme/dev/pxpx/!depreciated/frankly6/plugins/pixelpixel/mpseo`: the PixelPixel `mpseo` WinterCMS plugin, the pattern for D-90. Read `Plugin.php`, `classes/SeoInjectorManager.php` (`extend()`, `resolveExtensionOptions()`), `classes/SeoEntryRepository.php`, `classes/GenerateSeoEntriesJob.php`, `models/Entry.php`, `models/SeoSettings.php`, `components/SeoInjector.php`, `formwidgets/GoogleResultPreview.php`. A registering plugin: `/media/nvme/dev/pxpx/MOTOGAMERS/plugins/pixelpixel/blog/Plugin.php` (~340-420, `seoinjection.extend`).
|
||||
- `../examples/golem15-wintercms-starter/plugins/winter/pages/classes/page/fields.yaml`: WinterCMS `viewBag[meta_title]`/`meta_description` (the per-page SEO tab this phase improves on).
|
||||
|
||||
</canonical_refs>
|
||||
@@ -318,13 +354,14 @@ Out of scope: e-commerce structured data and product or category shop pages (no
|
||||
- **`conga` (River jobs):** for the crawler, IndexNow pings and sitemap rebuilds.
|
||||
|
||||
### Established Patterns
|
||||
- **Compiled plugins and capability interfaces** (`pact.Has*`). The SEO opt-in follows this pattern.
|
||||
- **Compiled plugins and capability interfaces** (`pact.Has*`). The form extension seam (D-91) follows this pattern.
|
||||
- **Nuxt layers** distributed as pnpm git dependencies pinned by tag, with overridable components. SEO rendering and the SSR guard belong in a layer that apps extend.
|
||||
- **The layers already render SSR.** `?page=N` pagination with canonical whitelisting exists; `rel=prev/next` is only on `<a>` links.
|
||||
|
||||
### Integration Points
|
||||
- **cabana form compilation:** needs a new generic seam that adds the SEO tab to opted-in models of other plugins. Nothing like it exists today.
|
||||
- **Journal plugin:** opts its Post, Category and Tag into SEO, gets the translated-slug fix and the H1 → H2 conversion, and registers its schema types.
|
||||
- **cabana form compilation:** needs the generic form extension with placement (D-91) and record events (D-92). Nothing like either exists today.
|
||||
- **Journal plugin:** adds the D-93 descriptors to its Post, Category and Tag types, gets the translated-slug fix and the H1 → H2 conversion, and registers its schema types. It does not import the SEO plugin.
|
||||
- **`sm-seo-plugin`:** new repo, mounted as a submodule at `plugins/golem15/seo` in the apps.
|
||||
- **sm-sitemap-plugin:** rewritten to the split index (breaking, D-32).
|
||||
- **Nuxt layers:** journal, menu and FAQ consume effective SEO, JSON-LD, breadcrumbs and the redirect middleware; the user plugin's auth pages get noindex.
|
||||
- **Applications:** grzybyfunkcjonalne (sitemap migration and robots move from Nuxt to the CMS), demo, summercms.io, and Płytarium, the likely first consumer.
|
||||
|
||||
Reference in New Issue
Block a user