Commit Graph

47 Commits

Author SHA1 Message Date
Jakub Zych
032d3dae45 feat: pagination forms and suffixes in resolve and site
- Pagination tab: query or path form, per-locale segment, title and
  description suffixes with %page% and %pages%
- ?page=1, /<segment>/1 and the other form answer 301 to the configured
  address in one hop with normalisation and strict URLs; a page value
  that is not a number answers not_found with pagination.invalid
- page 2 and later: self canonical with the page, index,follow, the
  suffixes (%pages% left for the layer), paged hreflang alternates
- GET /site answers pagination {form, param, segments} and home_crumb
2026-11-04 14:22:21 +01:00
Jakub Zych
fde5024b5a feat: publisher, local business and home crumb settings
- Publisher and Local business tabs with the C20 refusals (sameAs lines,
  person as local business, address, times, country, coordinates)
- the publisher node from the settings, as an Organization, a Person or
  the chosen local business type with address, geo and opening hours
- P8 publisher preview with Google's required and recommended findings
  (classes/schema Validate)
- the home crumb name on the General tab; every new key in the settings
  API and seo_update_settings
2026-11-04 14:14:59 +01:00
Jakub Zych
ea2ce24954 feat: JSON-LD graph, breadcrumbs and hreflang in the resolve answer (tracer)
- classes/schema builds one @graph per page (WebPage, article, profile,
  service, BreadcrumbList, VideoObject, WebSite, publisher) linked by @id
- breadcrumb trail through the primary parent chain, hreflang alternates
  with x-default, html_lang and og:locale in the resolve answer
- migration 202610280004 adds the publisher, local business, home crumb
  and pagination settings columns
2026-11-04 14:06:57 +01:00
Jakub Zych
397c126833 fix: name a content type's plain label in url_owned without translating it
A label that is not a phrase key (no ::) is used as written, so the
translator no longer logs it as a missing key.
2026-11-04 13:44:36 +01:00
Jakub Zych
2dfc606cdc feat: purge the site cache after SEO writes
- every write that records history (admin, MCP, batch, import, undo,
  automatic) queues the framework's site-cache purge (kind seo, slug the
  address or * for settings and robots.txt) after its commit, once per
  transaction; a rolled-back write never purges
- the record-deleted cleanup and the address tracking of saves outside the
  admin run in lagoon transactions, so their history purges as well
2026-11-04 13:44:12 +01:00
Jakub Zych
59f0457c69 feat: sitemap-aware batch conflict and the sitemap_blocked issue
- noindex_published fires only when a sitemap file lists the address now
  (read once per batch through contract.Listing)
- CMS issue sitemap_blocked (severity error, tool seo_update_robots,
  details line and rule): a listed address the production robots.txt
  keeps Googlebot from
- the SEO tab's P1 shows status.excluded_title with the reason (noindex,
  canonical elsewhere, redirect, or the type switched off in the sitemap
  settings via ListingSettings.TypesOff) as a plain callout
- robots.txt is composed once per check of many addresses
2026-11-04 13:39:31 +01:00
Jakub Zych
742a5b930b feat: site endpoint, llms.txt and the sitemap index in robots.txt
- GET /_seo/api/v1/site (public, noindex): site_url, default_locale,
  locales, unprefixed_locale, links.new_tab, html_sitemap (from the
  sitemap plugin's Listing), routes of every content type, root_files
  (verification files and the IndexNow key file) and llms
- GET /llms.txt: with llms_enabled (robots.txt screen, AI bots tab, off by
  default) and in production, the indexable pages from the sitemaps per
  group under the site name and llms_intro (else the default
  description); otherwise 404; seo_update_robots takes both fields
- robots.txt names the sitemap plugin's index unless
  golem15.seo.robots_sitemaps is set; the config default is now a comment
2026-11-04 13:32:12 +01:00
Jakub Zych
49d3879954 feat: links settings and outbound rules in resolve
- migration 202610280003: settings links_new_tab (default on),
  nofollow_domains and sponsored_domains; robots llms_enabled and
  llms_intro (used by the llms.txt commit)
- SEO settings Links tab: the new-tab switch and the two domain lists,
  one domain per line, lowercased, refused with settings.domain_invalid
  naming the line; writable through seo_update_settings
- the resolve answer of an ok page carries outbound {new_tab,
  nofollow_domains, sponsored_domains}; no existing key changes
2026-11-04 13:26:31 +01:00
Jakub Zych
26193ae4eb feat: route report endpoint
- POST /_seo/api/v1/registry/routes (public, bucket seo-notfound): the
  frontend server reports its static routes with the bearer token
  golem15.seo.route_report_token, else notfound_token (constant-time;
  both empty answer 404); at most 2000 paths
- unknown paths become registry pages (origin reported, kind page, a name
  from the last segment), known ones get reported_at; paths another
  content type owns and invalid ones are skipped; nothing is deleted or
  overwritten, and the report's time marks missing pages
2026-11-04 13:22:12 +01:00
Jakub Zych
9e802ec850 feat: page registry with its admin screen and signpost type
- golem15_seo_pages (migration 202610280002): the site's own frontend pages
  with an address and a name per locale, kind, origin, parent, content date
  and note; settings routes_reported_at
- SEO > Pages (golem15.seo.pages) after URL rules: list with kind, origin
  and missing filters and the missing row state, form with the P9 partial;
  the plugin's form extension saves the address and name per locale and
  refuses url_required, url_invalid, url_taken and url_owned
- menu item type seo-page with a content descriptor, so each page gets
  the SEO tab, resolves ok and is listed in the sitemaps grouped by kind
  (pages, offers, services, landing-pages, titled per locale)
- management API GET/POST /_seo/api/v1/pages, PUT/DELETE /pages/{id} and
  the MCP tools seo_list_pages, seo_create_page, seo_update_page,
  seo_delete_page (in this commit because the operations parity test maps
  every Pages operation to its tool)
2026-11-04 13:20:45 +01:00
Jakub Zych
17b810dfde feat: sitemap filter excludes noindex, canonicalised and redirected URLs (tracer)
- classes.SitemapFilter implements the sitemap plugin's contract.Filter on
  the production values: redirected, noindex and canonicalised addresses
  leave the sitemaps with their reason; staging lists the same set
- the plugin requires golem15.sitemap and publishes the filter in Register
- go.mod requires sm-sitemap-plugin, go.work mounts the sibling checkout
2026-11-04 13:02:39 +01:00
923d0f9c3a fix: the robots element follows the Go grammar and keeps a value it cannot parse (WR-24)
parseRobots accepted 2027-02-30 (Date.parse rolls it over) and refused
max-snippet:+5, both unlike classes/robots_value.go. A value it could not
parse showed every group as Inherit and the first click serialised the
empty model, dropping the stored directives. Dates are now checked as
calendar dates, a sign is accepted, and an unparsed value is kept with a
note, the controls off and an explicit Reset.
2026-10-27 15:43:32 +01:00
ed2d919aee fix: the CSV round trip keeps values that start with an apostrophe before a formula character (WR-21)
escapeCell left '=x as it was and unescapeCell then dropped its
apostrophe on import. A value of apostrophes followed by a formula
character now gets one more apostrophe on export, and the import drops
exactly one, so unescapeCell is the inverse of escapeCell.
2026-10-27 15:39:23 +01:00
882d4ee05f fix: image and URL references refuse a /\ path, which browsers read as another host (WR-20)
safeImageURL and ValidURLRef refused only //, so a stored og_image of
/\evil.example/x.png loaded a third-party image in the admin. Both now
refuse /\ like models.validTarget already does.
2026-10-27 15:38:56 +01:00
d8aa989dfc fix: entry generation applies an unseen address change instead of overwriting it (WR-19)
refreshEntry set entry.url to the descriptor's address unconditionally, so
a slug change the job saw before any RecordSaved listener lost its 301 for
good. An entry whose URL differs now goes through AddressChanged (via
auto), which creates the 301 and moves the URL; an empty URL is filled.
2026-10-27 15:38:15 +01:00
cff2d29fdd fix: set_url_seo and record imports refuse a locale the record does not have (WR-18)
A locale given with a URL write overrode the matched one unchecked, so
"xx" created an entry nothing ever reads while the answer reported
success. SetURLSEO (API, MCP, batch set_seo, records import) now requires
one of the record's locales and refuses others with api.locale_unknown.
2026-10-27 15:37:31 +01:00
24e6f384ca fix: an unknown redirect status, match or query is refused, not defaulted (WR-17)
The API, MCP, batches and the CSV import copied any value and Normalize
replaced it with the default, answering 201 for a 307 saved as a 301.
RedirectFields.Problems refuses values outside {301, 302, 410}, {exact,
pattern, regex} and {ignore, pass} with value_invalid; the import marks
such rows refused.
2026-10-27 15:36:21 +01:00
c8e0961740 fix: social images are validated on the API, MCP, batch and import paths (WR-16)
Only the admin seoimage field refused values that are not a path or an
http(s) URL; SetURLSEO, SaveRule and SaveSettings stored javascript:,
data: or protocol-relative values, which /resolve then served as
og:image. All three now refuse them with image.url_invalid.
2026-10-27 15:35:02 +01:00
59c37a8fea fix: a huge export or list page answers an empty page instead of panicking (WR-14)
(page-1)*perPage overflowed for a large page and sliced out of range.
Export compares the page with the page count before multiplying, and the
list endpoints cap the page number.
2026-10-27 15:33:21 +01:00
c2ba33ee94 fix: the runtime cycle log is keyed by rules and bounded (WR-13)
redirectHopsLogged was a never-evicted map keyed by the anonymous
request's URL with its query, so requests to a looping address with fresh
query strings grew it without limit and logged each one. The log is now
keyed by the cycle's rule ids, repeats at most once an hour and starts
over at 1024 keys.
2026-10-27 15:32:29 +01:00
98fbeb6791 fix: resolve and the 404 report refuse NUL and invalid UTF-8 addresses (WR-12)
A decoded path such as /%00 or /%FF reached Postgres as text and answered
500 with an error log line per request. parseTarget refuses a path or
query that is not valid UTF-8 or holds NUL (422), NotFoundPath does the
same for the logged address, and a referer is cleaned of both.
2026-10-27 15:31:56 +01:00
d20f14fe67 fix: undo locks its history entries and marks them only once (WR-11)
Two concurrent undos of one entry both passed the already-undone check and
both restored. Undo and UndoBatch now read the entries FOR UPDATE, and the
final mark is conditional on undone_at IS NULL, refusing with
already_undone when another undo got there first.
2026-10-27 15:30:13 +01:00
c2f136ad7d fix: serialise redirect writes so concurrent saves cannot store a loop (WR-10)
The save-time simulation read the rules at READ COMMITTED without a lock,
so A -> B and B -> A written concurrently both passed. CheckRedirect,
AddressChanged and every batch now take a transaction-level advisory lock
before they read the rules.
2026-10-27 15:29:08 +01:00
cb614cb1fb fix: address changes run the loop simulation and validate their addresses (WR-09)
AddressChanged wrote its 301 and re-pointed rules without Problems or the
save-time simulation, and removed only an exact rule whose source equals
the new address literally. It now removes every exact rule that matches
the new address with its options, simulates the 301 and the re-pointing
with the stored rules and writes nothing when that loops, stores an
absolute old address on the site as its path, and skips an address that
is not a path on the site.
2026-10-27 15:27:21 +01:00
58b2504fe3 fix: an exact redirect with a query wins over the path-only rule (WR-08)
Match tried the path-only key before the path?query key, so a rule for
/products?id=5 never fired while /products had one.
2026-10-27 15:25:39 +01:00
2d81c261b5 fix: exact redirects with a trailing slash or an upper-case query fire at runtime (CR-03)
The resolver fetched exact rules with lower(source) IN keys, while the keys
drop one trailing slash and keep the query as written, so a source stored
as /old/ or /list?Page=2 was never read. A new migration adds the stored
generated column source_key (the SQL form of exactKey) with an index for
exact rules, and the fetch compares it.
2026-10-27 15:20:19 +01:00
9e45ddc848 test: unit tests for the SEO classes
Pure tests of the classes package (no database): the resolver's value
chains with every layer present and absent, robots per directive, the
staging lock and drafts; the redirect graph (chains, flattened and
back-and-forth graphs, loops by exact, pattern and regex probes, the
10-hop cap); URL normalisation across every address rule; the robots
grammar over every directive combination; Google's robots.txt matcher
examples; templates and unknown placeholders; CSV export and import;
owner resolution; and every kind of the issue report.

go.mod: gorm.io/driver/postgres becomes a direct requirement (the tests'
dry-run database).
2026-10-27 13:21:38 +01:00
6d54fc7001 refactor: build the issue report from loaded data
Issues loads the entries, rules, redirects, robots row and noindex
addresses, then issuesOf builds the report and blockedNoindex checks the
addresses against robots.txt, so the report's rules can be unit-tested
without a database. No behaviour change.
2026-10-27 13:19:06 +01:00
79567b3e18 fix: a loop through the site's absolute URL lists each address once
The loop and chain path of a redirect notice appended the final target
when it was an absolute URL of the site itself, so a loop read
"/a → /b → /a → https://example.test/a". The site URL now counts as
internal, as it does when the chain is followed.
2026-10-27 13:15:36 +01:00
e957c00866 refactor: run the redirect simulation over a rule list
CheckRedirect loads the stored rules and hands them to
checkRedirectRules, which needs no database, so the loop and chain rules
can be unit-tested. No behaviour change.
2026-10-27 13:14:06 +01:00
ab3b219fec feat: batches with dry run, undo, and CSV import and export
- POST /_seo/api/v1/batch runs changes in one transaction: dry_run
  answers the diff and the conflicts (loops, chains, noindex on a
  published record, canonicals to redirected or noindex addresses) and
  rolls back; a refused change refuses the batch (D-12)
- undo of a change or a whole batch from History and through MCP, with
  the undo_stale, undo_loop and undo_gone refusals (D-13)
- CSV export and import of redirects, rules and records by URL: BOM,
  comma or semicolon, formula cells escaped, a dry-run preview, all or
  nothing with via import; the screen's apply token is HMAC-SHA256 over
  the file and a fingerprint of the data (transfer.stale)
- Import and export singleton with the seo_transfer widget action,
  History record actions undo and undo_batch, navigation entry
- MCP tools undo_change, undo_batch, apply_batch, export_csv, import_csv
- TestBatch, TestUndo, TestCSVTransfer, TestLoopRefused mcp and import
2026-10-27 12:35:26 +01:00
aa0c3bcacf feat: management API and an MCP tool per SEO admin operation
- /_seo/api/v1 routes for the effective SEO of a URL with sources and
  owner, URL rules, redirects (loops refused with redirect_loop), the
  404 log actions, robots.txt and its tester, settings and history
- the CMS-data issue report: missing and duplicate titles and
  descriptions, rules over records, redirect chains, canonicals to
  noindex or redirected addresses, noindex addresses robots.txt blocks
- 24 MCP tools on those routes (backend guard, golem15.seo.manage)
- AdminOperations maps every admin operation to its tool;
  TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift
- robots.txt writes go through classes.SaveRobots from the admin too
2026-10-27 12:17:36 +01:00
cadf6ad805 feat: SEO by URL over the management API and MCP (tracer)
- PUT /_seo/api/v1/seo writes to the record behind a URL in its locale,
  else to an exact URL rule (force_rule writes a rule); the answer says
  where (D-11)
- management routes resolve the backend administrator from a token or
  the MCP dispatch and require golem15.seo.manage (D-10, D-15)
- MCP tool set_url_seo; history records via mcp
- classes.WithTx, FindOwner, SetURLSEO, SaveRule and DeleteRule
2026-10-27 12:06:31 +01:00
ec74062624 feat: verification files API
- GET /_seo/api/v1/verification/{name} serves a verification file stored
  in the SEO settings with its content type, nosniff and a sandboxing CSP
  (D-05, D-103); the name pattern is enforced in the route and the
  lookup, and no route is registered at the site root
2026-10-27 11:52:55 +01:00
51413fbda4 feat: robots.txt editor and tester screen
- golem15.seo.robots singleton (UI-SPEC C10): staging callout and the
  served file (P4); the plugin's own extension adds the editor, the
  tester and the AI bots tab, saved to the robots row with history
- field type robotstxt (E4, live lint, size meter; Decode refuses over
  500 KB) and widget golem15-seo-robotstester (E5) on robots_test, which
  tests the unsaved text and switches through grobotstxt
- saving rules that block a noindex address answers robots.blocks_noindex
2026-10-27 11:51:41 +01:00
3dd936202f feat: robots.txt served file, AI bots and the non-production lock
- github.com/jimsmart/grobotstxt v1.0.3 (D-99, decision note in the
  framework repo)
- golem15_seo_robots singleton: rules and AI bot switches; eleven bots
  incl. Claude-User, all allowed by default
- GET /robots.txt: rules, a Disallow group per switched-off bot and the
  golem15.seo.robots_sitemaps lines; outside production the lock with
  X-Robots-Tag; never a 5xx (last good body, else allow-all)
- TestRobots on Google's matcher with the deciding line, and the
  noindex-blocked check (D-24)
2026-10-27 11:45:44 +01:00
4cb0fbfebb feat: URL normalisation, strict URLs and the one-hop redirect decision
- Normalize: https, www or bare host, double slashes and index files,
  trailing slash add/remove, optional ASCII lowercasing (defaults change
  nothing but scheme, slashes and index files)
- strict URLs through signpost MatchPath: a path that names a record
  without being its address 301s to the record's address
- resolve runs normalisation, redirects, strict URLs, then SEO; the
  location is the final address, so a visitor makes one hop
- P1 warns when a record's canonical URL redirects
2026-10-27 11:41:45 +01:00
6eb6cb7a4d feat: 404 log and report endpoint
- POST /_seo/api/v1/notfound with the golem15.seo.notfound_token bearer
  (constant-time, off when unset), URL and referer caps, a row cap with
  an hourly warning, throttle bucket seo-notfound
- golem15.seo.notfound list by hits with Resolved rows, a preview with the
  resolved hint, a prompted 301 and a 410 record action, a bulk 410 that
  refuses a selection with a covered address
- every /_seo/api/v1 answer carries X-Robots-Tag: noindex
2026-10-27 11:35:25 +01:00
163b0aaf98 feat: redirects screen
- golem15.seo.redirects list and form (UI-SPEC C8): chain rows marked,
  status, match, origin and chain filters, the navigation item lands here
- a field-less extension on the controller refuses a loop with
  redirects.loop on target, answers the chain notice and records history
- the status dropdown posts text, read through RedirectStatus
2026-10-27 11:30:56 +01:00
34a65b846e feat: redirect engine with loop refusal and chain notices
- rule set: exact sources by key, patterns by literal prefix, regex by id
  (stdlib RE2, compiled once per source); ignore case, trailing slash,
  query ignore/pass, a source with ? matches its query exactly
- targets: captures escaped, a relative target never leaves the site, an
  absolute one keeps its host
- SaveRedirect validates, simulates the rule in place (exact, pattern and
  regex probes, 10 hops) and refuses a loop; chains answer the notice
- resolve follows the chain to its final target, 410 answers gone, a
  runtime cycle answers not_found and is logged once
2026-10-27 11:26:42 +01:00
e31ac7c1c6 feat: address-change 301 (tracer)
- golem15_seo_redirects table and Redirect model
- AddressChanged: old -> new 301 in the save transaction, earlier
  redirects pointed at the new address, self-loops dropped
- the SEO tab save compares descriptor URLs with the entry URLs and
  answers the redirects.auto_created notice
- resolve answers redirect with the absolute target for an exact source
2026-10-27 11:20:59 +01:00
93ddbcc9ef feat: SEO field types and the complete SEO tab 2026-10-27 11:04:03 +01:00
ab8905c429 feat: entry generation job and cleanup on delete 2026-10-27 10:48:02 +01:00
e8a6b9fc77 feat: SEO change history 2026-10-27 10:45:30 +01:00
9f1082d62d feat: full precedence resolver with value sources 2026-10-27 10:39:55 +01:00
30edb8adee feat: per-type settings tabs and the SEO tab permission 2026-10-27 10:36:32 +01:00
fb9590f099 feat: plugin scaffold, entries and the SEO tab tracer 2026-10-27 10:27:46 +01:00