docs: registry, site endpoint, llms.txt and links in README

The sitemap filter and the golem15.sitemap requirement, the page registry
and its route report, the site answer, llms.txt, the Sitemap line from the
index, the Links tab and outbound, the site-cache purge after SEO writes,
the new MCP tools, config keys, API identifiers and tests.
This commit is contained in:
Jakub Zych
2026-11-04 13:47:12 +01:00
parent 397c126833
commit df93c86001

View File

@@ -12,9 +12,9 @@ Module `git.golem15.com/golem15/sm-seo-plugin`, package `seo`, plugin ID `golem1
The plugin owns all SEO data of a site; the framework only carries the generic seams it builds on: form extensions (`pact.AdminFormExtension`), record events (`pact.RecordSaved`, `pact.RecordDeleted`) and content descriptors (`pact.ContentDescriptor` in signpost). Content plugins never import it. A content type takes part by registering a content descriptor on its menu item type; the plugin then adds an **SEO** tab to that type's admin form and stores the values in its own table, keyed by the record's morph type, its id and the locale.
The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for the page it renders. The answer is first the redirect decision (URL normalisation, redirects, strict URLs, reached in one hop), then the effective SEO. Every value is resolved with one precedence: **URL rule > record > per-type template or global settings > values derived from the content**, and the resolver records the source of each value. The frontend enforces the decision; the plugin registers no routes at the site root except `/robots.txt`.
The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for the page it renders. The answer is first the redirect decision (URL normalisation, redirects, strict URLs, reached in one hop), then the effective SEO. Every value is resolved with one precedence: **URL rule > record > per-type template or global settings > values derived from the content**, and the resolver records the source of each value. The frontend enforces the decision; the plugin registers no routes at the site root except `/robots.txt` and `/llms.txt`.
`Requires()` is empty: content types are found through signpost, never by plugin ID.
`Requires()` names `golem15.sitemap` (`SitemapPluginID`): the plugin publishes the sitemap plugin's `contract.Filter` (`classes.SitemapFilter`) in `Register` and reads the sitemaps only through that plugin's `contract` package (`contract.Listing`). Content types are still found through signpost, never by plugin ID; the sitemap plugin keeps working without this one.
## Features
@@ -34,14 +34,20 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
- **Non-production lock.** Outside the `production` environment every resolve answer is `noindex, nofollow` with `staging: true`, and `/robots.txt` is `User-agent: *` / `Disallow: /` with `X-Robots-Tag: noindex, nofollow`, whatever is stored. It depends on the environment only; no setting turns it off.
- **History** (`golem15_seo_revisions`): every SEO write (the SEO tab, rules, redirects, robots.txt, settings, the 404 log) is recorded with author, time, via (admin, MCP, import, auto for address changes, undo) and the changed fields before and after. Deleted records keep their values in history.
- **Undo** of one change or of a whole batch, from the History preview (record actions **Undo this change** and **Undo the whole batch**) and through MCP. An undo is refused when a later change touched the same values (`history.undo_stale`, naming who and when), when it would create a redirect loop (`history.undo_loop`) or when the record is gone (`history.undo_gone`); an undo is itself recorded.
- **Batches** (D-12): a list of changes (SEO by URL, redirects, URL rules) applied all or nothing under one batch id. A dry run answers the diff and the conflicts (loops, chains, noindex on a published record's address, a canonical pointing to a redirected or noindex address) and writes nothing.
- **Batches** (D-12): a list of changes (SEO by URL, redirects, URL rules) applied all or nothing under one batch id. A dry run answers the diff and the conflicts (loops, chains, noindex on an address a sitemap file lists now, a canonical pointing to a redirected or noindex address) and writes nothing.
- **CSV import and export** of redirects, URL rules and the SEO of records (by URL), in the **Import and export** screen (element `golem15-seo-transfer`) and through MCP. Files are UTF-8 with a BOM, comma or semicolon; exported cells that start a formula (`=`, `+`, `-`, `@`, tab, CR), or apostrophes followed by one, are prefixed with an apostrophe, and the import drops exactly that one again, so a re-imported export is unchanged. An import is previewed row by row (new, changed, unchanged, refused, with notes) and applied as one batch with via `import`, or not at all while a row is refused. The screen's apply token is an HMAC-SHA256 under the app key over the file and a fingerprint of the data the preview read; it is refused with `transfer.stale` when the data changed. The screen previews files up to 60 KB and downloads exports up to 250 KB; MCP imports take up to 900 KB per call and exports come in pages of 1000 rows.
- **Record events.** A deleted record loses its entries after commit; a created record gets its address snapshot; an updated record whose address changed gets its 301.
- **Entry generation.** The `golem15.seo.generate_entries` job (conga) creates missing entries with the current address of every published record and refreshes the effective-value cache; a record whose address changed without a record event gets its address change (with its 301) like an admin save. Editor values are never changed.
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage` and `robotstxt`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget), `golem15-seo-image`, `golem15-seo-robotstxt` (the robots.txt editor), `golem15-seo-robotstester` (the tester widget) and `golem15-seo-transfer` (the import and export widget).
- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, redirects, the 404 log, robots.txt, settings and history, in the admin and through MCP.
- **Management API and MCP.** Every SEO admin operation has a JSON route under `/_seo/api/v1` and an MCP tool bound to it (pier, prefix `seo_`). Tools address pages by URL: a change goes to the record behind the URL, else to an exact URL rule, and the answer says where. A read grant sees only the read tools. `AdminOperations` maps each admin operation to its tool, and a test fails when one has none.
- **Issue report.** Missing and duplicate titles and descriptions, URL rules overriding a record, redirect chains, canonicals pointing to a noindex or redirected address, and noindex addresses robots.txt blocks, each with the tool that fixes it.
- **Issue report.** Missing and duplicate titles and descriptions, URL rules overriding a record, redirect chains, canonicals pointing to a noindex or redirected address, noindex addresses robots.txt blocks, and sitemap addresses the production robots.txt keeps Googlebot from (`sitemap_blocked`, severity `error`, with the deciding line), each with the tool that fixes it.
- **Sitemap filter.** The sitemaps leave out every address whose effective SEO is noindex, whose canonical URL points elsewhere or that redirects (normalisation or a redirect rule), with the reason `noindex`, `canonical` or `redirect`. The filter decides on the production values, so staging and production list the same set. The SEO tab's status shows **Not in the sitemaps** with the reason, also when the content type is switched off in the sitemap settings.
- **Page registry** (`golem15_seo_pages`, SEO › **Pages**): the site's own frontend pages (home, contact, offer pages) with an address and a name per locale, a kind (page, offer, service, landing), a parent, the date their content changed and a note. The registry is the content type `seo-page`, so each page gets the SEO tab, resolves `ok` and is listed in the sitemaps, grouped by kind into `sitemap-pages`, `sitemap-offers`, `sitemap-services` and `sitemap-landing-pages` files. An address another page or another content type owns is refused (`pages.url_taken`, `pages.url_owned`).
- **Route report.** The frontend server reports its static routes; unknown ones become registry pages, a reported page missing from a newer report is marked **Not on the site**, and nothing is ever deleted or overwritten.
- **Site answer** (`GET /_seo/api/v1/site`) with the facts the frontend layer needs, and **llms.txt** (off by default) listing the indexable pages from the sitemaps.
- **Links** settings: links to other sites open in a new tab (on by default), and domains whose links always get `nofollow` or `sponsored`; the resolve answer carries them as `outbound`.
- **Site cache purge.** Every SEO write (admin, MCP, batch, import, undo, automatic) asks the frontend to drop its cached pages after the commit, once per transaction, through the framework's site-cache purger (`site_cache.purge_url`, `site_cache.purge_token`).
## Usage
@@ -49,11 +55,13 @@ Mount the plugin in an application as a git submodule at `plugins/golem15/seo`,
```yaml
plugins:
- id: golem15.sitemap
module: git.golem15.com/golem15/sm-sitemap-plugin
- id: golem15.seo
module: git.golem15.com/golem15/sm-seo-plugin
```
The plugin's own `go.mod` and `go.work` replace the framework with `../summercms.go` for a sibling checkout.
The SEO plugin requires the sitemap plugin, so both are listed. The plugin's own `go.mod` and `go.work` replace the framework with `../summercms.go` and the sitemap plugin with `../sm-sitemap-plugin` for sibling checkouts.
### Making a content type take part
@@ -99,10 +107,13 @@ The type then gets the SEO tab, a tab in the SEO settings and a place in the res
"og": {"title": "About | Acme", "description": "Who we are", "image": "https://example.com/storage/media/seo/abc-about.png", "url": "https://example.com/pages/about"},
"twitter": {"card": "summary_large_image", "site": "@acme"},
"verification": []
}
},
"outbound": {"new_tab": true, "nofollow_domains": ["example.org"], "sponsored_domains": []}
}
```
`outbound` (present on `ok` answers) carries the settings' Links tab: open links to other sites in a new tab, and the domains (subdomains included) whose links always get `nofollow` or `sponsored`. The layer treats an absent key as empty.
The pipeline runs normalisation, then redirects on the normalised path (followed to the final target), then strict URLs, then the SEO. When the URL is not in its final form the answer is a redirect, reached in one hop:
```json
@@ -131,7 +142,7 @@ Disallow: /
Sitemap: https://example.com/sitemap.xml
```
The stored rules come first, then one group per switched-off AI bot, then one `Sitemap:` line per entry of `golem15.seo.robots_sitemaps`, absolute on the site URL. Outside production the body is `User-agent: *` / `Disallow: /` with `X-Robots-Tag: noindex, nofollow`. It never answers a 5xx: when the stored file cannot be read it serves the last body it served, else `User-agent: *` / `Disallow:` (allow all), and logs the error.
The stored rules come first, then one group per switched-off AI bot, then the `Sitemap:` line: the sitemap plugin's index (`contract.ListingSettings.IndexURL`, its site address plus `/sitemap.xml`; the per-type files are never listed, the index names them), or one line per entry of `golem15.seo.robots_sitemaps` when that key is set, absolute on the site URL. Outside production the body is `User-agent: *` / `Disallow: /` with `X-Robots-Tag: noindex, nofollow`. It never answers a 5xx: when the stored file cannot be read it serves the last body it served, else `User-agent: *` / `Disallow:` (allow all), and logs the error.
The **robots.txt** screen (a singleton) has the editor, the tester and **What crawlers get** on its first tab and the AI bot switches on the second. The tester sends the editor's text and the switches as they are on screen, saved or not, and answers allowed or blocked for the chosen crawler with the deciding line, or that a bot switch blocked it.
@@ -139,6 +150,34 @@ The **robots.txt** screen (a singleton) has the editor, the tester and **What cr
`GET /_seo/api/v1/verification/{name}` serves a verification file from the settings tab Verification (name `[A-Za-z0-9._-]+\.(html|txt|xml)`) with the content type of its extension, `X-Content-Type-Options: nosniff` and a sandboxing Content-Security-Policy; any other name is `404`. The frontend serves it at the site root; the plugin registers no `/{name}` route, which would capture every one-segment page of a site served at `GET /`. Every `/_seo/api/v1` answer carries `X-Robots-Tag: noindex`.
### The page registry and the route report
**SEO › Pages** (controller `golem15.seo.pages`, after URL rules) lists the registry by address with the kind, origin, content date and the **Not on the site** state; the form takes the address and the name per locale (a locale left empty has no version of the page), the kind, the parent and the content date (the sitemap's lastmod), with the SEO tab next to it. A changed address gets its 301 like any record.
`POST /_seo/api/v1/registry/routes` takes `{"routes": ["/", "/kontakt", "/o-nas"]}` from the frontend server with `Authorization: Bearer <token>`: `golem15.seo.route_report_token`, else `golem15.seo.notfound_token` (compared in constant time). It answers `200` with `{"inserted", "updated", "skipped", "reported_at"}`; a missing or wrong token is `401`, no token configured `404`, more than 2000 paths `422`. An unknown path becomes a page with origin `reported`, kind `page` and a name from its last segment; a known page only gets `reported_at`; a path another content type owns, or one that is no path, is skipped. A report never deletes a page or changes a value an editor set; a reported page the newest report did not name shows **Not on the site** and a warning on its form. Throttle bucket `seo-notfound`. A content type whose file group is also `pages` shares the file with the registry's pages.
### The site answer and llms.txt
`GET /_seo/api/v1/site` (public, bucket `seo-resolve`, `X-Robots-Tag: noindex`) answers the facts the frontend layer needs, all public:
```json
{
"site_url": "https://example.com",
"default_locale": "pl",
"locales": ["pl", "en"],
"unprefixed_locale": "pl",
"links": {"new_tab": true},
"html_sitemap": {"enabled": true, "paths": {"pl": "/mapa-strony", "en": "/sitemap"}, "page_size": 150},
"routes": [{"type": "journal-post", "pattern": "/blog/:slug"}],
"root_files": ["google0123.html", "0123456789abcdef0123456789abcdef.txt"],
"llms": false
}
```
`routes` lists each content type's layered URL pattern (the registry has none); `root_files` the verification files and the IndexNow key file while IndexNow is on; `html_sitemap` comes from the sitemap plugin's `contract.Listing`.
`GET /llms.txt` (bucket `seo-robots`) is off by default. With **Serve llms.txt** on (robots.txt screen, AI bots tab, or `seo_update_robots` with `llms_enabled`) and in production it answers `text/plain`: `# <site name>`, the introduction (`llms_intro`, else the default meta description) as a quote, then one `## <group>` section per sitemap group with `- [title](url)` lines of the default locale's listed addresses, in the content types' order. Addresses the sitemaps leave out never appear. Off, or outside production, it is `404`.
### Management API and MCP tools
The management routes live under `/_seo/api/v1` (bucket `seo-api`, 300 requests a minute per client IP). Each resolves the administrator from a backend JWT, a personal API token (scope `golem15.seo.*`) or pier's in-process dispatch, so an MCP call runs as the backend account linked to the MCP user; each answers `401` without one and `403` without `golem15.seo.manage`. Field refusals are `422 {"error": "validation_failed", "errors": {...}}`; a redirect that would create a loop is `422 {"error": "redirect_loop", "path": [...], "message": "..."}`, so the tool call fails; a missing row is `404`. History records these writes with via `mcp` when they come through MCP, and redirects created through MCP have origin `mcp`.
@@ -150,20 +189,22 @@ The management routes live under `/_seo/api/v1` (bucket `seo-api`, 300 requests
| `list_rules`, `show_rule`, `create_rule`, `update_rule`, `delete_rule` | `GET/POST /rules`, `GET/PUT/DELETE /rules/{id}` | URL rules. |
| `list_redirects`, `show_redirect`, `create_redirect`, `update_redirect`, `delete_redirect` | `GET/POST /redirects`, `GET/PUT/DELETE /redirects/{id}` | Redirects; a chain is saved with a warning. |
| `list_not_found`, `redirect_not_found`, `gone_not_found`, `delete_not_found` | `GET /notfound`, `POST /notfound/{id}/redirect`, `POST /notfound/{id}/gone`, `DELETE /notfound/{id}` | The 404 log and its actions; an address a rule answers is `409`. |
| `show_robots`, `update_robots`, `test_robots` | `GET/PUT /robots`, `GET /robots/test?url=&agent=` | robots.txt, the AI bot switches and the tester on the saved file. |
| `show_settings`, `update_settings` | `GET/PUT /settings` | The SEO settings and, under `types`, the per-type settings. |
| `show_robots`, `update_robots`, `test_robots` | `GET/PUT /robots`, `GET /robots/test?url=&agent=` | robots.txt, the AI bot switches, `llms_enabled` and `llms_intro`, and the tester on the saved file. |
| `show_settings`, `update_settings` | `GET/PUT /settings` | The SEO settings (including `links_new_tab`, `nofollow_domains`, `sponsored_domains`) and, under `types`, the per-type settings. |
| `list_history`, `show_history` | `GET /history`, `GET /history/{id}` | History, filtered by `via`, `kind` and `batch`. |
| `undo_change`, `undo_batch` | `POST /history/{id}/undo`, `POST /history/batches/{batch}/undo` | Undo; refusals are `409` with `undo_stale`, `undo_loop`, `undo_gone` or `already_undone`. |
| `apply_batch` | `POST /batch` | Changes (`set_seo`, `create_redirect`, `update_redirect`, `delete_redirect`, `create_rule`, `update_rule`, `delete_rule`) all or nothing; `dry_run` answers the diff and the conflicts. A refused change is `422` (`redirect_loop` with the change and the path, or `batch_refused`). |
| `export_csv` | `GET /export?dataset=&delimiter=&page=` | One page of 1000 rows as CSV text. |
| `import_csv` | `POST /import` | A CSV file of a dataset; `dry_run` previews it. A file over 900 KB is `413`; `missing_column`, `unreadable` and `import_refused` are `422`. |
| `list_issues` | `GET /issues` | The CMS-data issue report with a count per kind. |
| `list_pages`, `create_page`, `update_page`, `delete_page` | `GET/POST /pages`, `PUT/DELETE /pages/{id}` | The page registry: `url` and `title` per locale (a plain string is the default locale's), `kind`, `parent_id`, `content_updated_at`, `note`; the list filters by `id`, `kind`, `origin` and `missing`. A page created through MCP has origin `mcp`. |
Lists take `page`, `per_page` (at most 200) and `search`, and answer `{"data": [...], "page", "per_page", "total"}`. GET tools are read-only; the deletes and `gone_not_found` are destructive.
### Admin
- Navigation item **SEO** (icon `search`), landing on **Redirects**, with the side menu **Redirects**, **404 log**, **URL rules**, **robots.txt**, **History** and **Import and export**.
- Navigation item **SEO** (icon `search`), landing on **Redirects**, with the side menu **Redirects**, **404 log**, **URL rules**, **Pages**, **robots.txt**, **History** and **Import and export**.
- The SEO settings have a **Links** tab (after Addresses): the new-tab switch and the nofollow and sponsored domain lists, one domain per line (`settings.domain_invalid` names a bad line).
- Settings tile **SEO settings** (`seo_settings`), a link to the settings singleton controller.
- Every SEO screen and the SEO tab require `golem15.seo.manage`. Without it an administrator sees no SEO tab, and a posted SEO value changes nothing.
@@ -179,6 +220,16 @@ The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/sr
| `AdminOperations`, `AdminOperation`, `ContentController` | Every SEO admin operation (controller, kind) and its MCP tool; `ContentController` (`*`) stands for the form of every content type. |
| `OpList`, `OpShow`, `OpCreate`, `OpUpdate`, `OpDelete`, `OpExtensionSave`, `OpUpload`, `OpRecordAction`, `OpBulkAction`, `OpWidgetAction` | The kinds of an admin operation. |
| `RobotsTestAction` | `robots_test`, the tester's widget action. |
| `SitemapPluginID` | `golem15.sitemap`, the plugin `Requires()` names. |
| `classes.SitemapFilter` | The published `contract.Filter`: `Decide` (reasons `noindex`, `canonical`, `redirect` on the production values; registry pages grouped by kind) and `Groups` (the kinds' groups with their titles per locale). |
| `classes.PageMenuItemType`, `classes.PageTypeKey`, `classes.PagesController`, `classes.PageModel`, `classes.PageGroup` | The registry's menu item type `seo-page` and its content descriptor, and the file group of a kind. |
| `classes.PageWrite`, `classes.RouteReport`, `classes.ValidPagePath`, `classes.PageMissing` | A registry write, a route report's outcome, the address check and the missing state. Service: `CheckPage`, `SavePage`, `DeletePage`, `ApplyPageTexts`, `ReportRoutes`, `RouteReportToken`, `PageAt`. |
| `classes.SiteAnswer` | `Service.Site`, the site answer. `Service.LLMSText` builds llms.txt. |
| `classes.Outbound`, `classes.OutboundOf` | The resolve answer's `outbound` from the settings. |
| `classes.SitemapExclusion` | `Service.RecordSitemapExclusions`, why the sitemaps leave out a record's addresses; `Service.SitemapListed` reads the listed addresses; `Service.SitemapLines` the robots.txt `Sitemap:` addresses. |
| `classes.SiteCacheKind` | `seo`, the kind of the site-cache purge after an SEO write. |
| `classes.IssueSitemapBlocked`, `classes.SeverityError` | The `sitemap_blocked` issue kind and its severity. |
| `controllers.PagesControllerID` | The Pages screen. |
| `classes.Service` | The single entry point for SEO reads and writes: `SaveRecord`, `Record` (history), `GenerateEntries`, `EnqueueGenerate`, `RecordDeleted`, `RecordCreated`, `Resolver`, `SiteURL`, `Staging`. Redirects: `SaveRedirect`, `DeleteRedirect`, `CheckRedirect`, `AddressChanges`, `AddressChanged`. 404 log: `ReportNotFound`, `ResolveNotFound`. robots.txt: `ServedFile`, `TestRobots`, `BlockedNoindex`, `SaveRobots`. `VerificationFileByName`. Management: `FindOwner`, `SetURLSEO`, `SaveRule`, `DeleteRule`, `SaveSettings`, `DeleteNotFound`, `Issues`. |
| `classes.Owner`, `classes.URLSEOWrite`, `classes.URLSEOResult` | The owner of a URL (`classes.WrittenToRecord` or `classes.WrittenToRule`) and a write addressed by URL. |
| `classes.Issue` | One finding of the issue report (`classes.IssueMissingTitle` and the other kinds). |
@@ -199,7 +250,7 @@ The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/sr
| `classes.FieldTypes` | The SEO admin field types. |
| `classes.WithVia`, `classes.ViaOf` | The writer history records (`admin`, `mcp`, `import`, `auto`, `undo`). |
| `controllers.PermissionManage` | `golem15.seo.manage`. |
| `models.Entry`, `models.Rule`, `models.Redirect`, `models.NotFound`, `models.Robots`, `models.Settings`, `models.TypeSetting`, `models.Revision` | The plugin's models. |
| `models.Entry`, `models.Rule`, `models.Redirect`, `models.NotFound`, `models.Robots`, `models.Settings`, `models.TypeSetting`, `models.Revision`, `models.Page` | The plugin's models. |
## Configuration
@@ -209,7 +260,8 @@ The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/sr
| `golem15.seo.unprefixed_locale` | `""` | The locale whose public URLs carry no locale prefix; empty leaves the choice to each content type. |
| `golem15.seo.notfound_token` | `""` | The bearer token of `POST /_seo/api/v1/notfound`; empty turns the endpoint off. |
| `golem15.seo.notfound_max_rows` | `10000` | The most addresses the 404 log holds. |
| `golem15.seo.robots_sitemaps` | `["/sitemap.xml"]` | The sitemap addresses robots.txt names, one `Sitemap:` line each, absolute on the site URL. |
| `golem15.seo.robots_sitemaps` | unset | The sitemap addresses robots.txt names, one `Sitemap:` line each, absolute on the site URL. Unset, robots.txt names the sitemap plugin's index. |
| `golem15.seo.route_report_token` | `""` | The bearer token of `POST /_seo/api/v1/registry/routes`; empty uses `notfound_token`, and both empty turn the route report off. |
The non-production lock reads the application environment (`SUMMER_ENV`); it has no key of its own. The address rules (host, trailing slash, lowercase) are settings, on the Addresses tab.
@@ -221,7 +273,8 @@ The non-production lock reads the application environment (`SUMMER_ENV`); it has
## Dependencies
- SummerCMS v0.5.1 modules: `pact`, `party`, `backpack`, `cabana`, `signpost`, `lagoon`, `conga`, `surf`, `wire`, `bouncer`, `phrasebook`, `bonfire`, `compass`, `diary`.
- SummerCMS v0.6.0 modules: `pact`, `party`, `backpack`, `cabana`, `signpost`, `lagoon`, `conga`, `cooler` (the site-cache purge), `surf`, `wire`, `bouncer`, `phrasebook`, `bonfire`, `compass`, `diary`.
- `git.golem15.com/golem15/sm-sitemap-plugin`, its `contract` package only (the Filter the plugin publishes and the Listing it reads); the plugin requires `golem15.sitemap`.
- `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `gocloud.dev/blob` (the media bucket for social images), `github.com/goccy/go-yaml` (the per-type settings tabs and robots form documents).
- `github.com/jimsmart/grobotstxt` v1.0.3, the robots.txt matcher (decision note `.planning/notes/2026-10-27-robots-txt-grobotstxt.md` in the framework repository).
- Element toolchain (development only, exact pins): `typescript`, `vite`, `vitest`, `happy-dom`. The bundle has no runtime dependencies.
@@ -237,4 +290,4 @@ npm ci && npm run typecheck && npm test && bash scripts/check-dist.sh
The element tests (`tests/elements`, vitest on happy-dom) drive the widget elements through a fake of the admin's widget host (`tests/elements/host.ts`), including a host that drops an action.
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`), batches, undo and CSV (`TestBatch`, `TestUndo`, `TestCSVTransfer`, and the `mcp` and `import` cases of `TestLoopRefused`). `TestEveryAdminOperationHasTool` and `TestMCPReadToolsAreGET` run without Docker.
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`), batches, undo and CSV (`TestBatch`, `TestUndo`, `TestCSVTransfer`, and the `mcp` and `import` cases of `TestLoopRefused`). The sitemap, the registry and the site-level endpoints are covered by `TestSitemapFilterTracer`, `TestFilterReasons`, `TestNoindexPublishedConflict`, `TestSitemapBlockedIssue`, `TestRegistry`, `TestRouteReport`, `TestSiteEndpoint`, `TestLLMSTxt`, `TestRobotsSitemapLine`, `TestSettingsLinks` and `TestPurgeAfterWrite`; the fixture boots the sitemap plugin next to the SEO plugin. `TestEveryAdminOperationHasTool` and `TestMCPReadToolsAreGET` run without Docker.