Commit Graph

69 Commits

Author SHA1 Message Date
Jakub Zych
f2c93aeabe feat: crawler checks for status, sitemaps, canonical, titles and headings
- page_status, sitemap_error, sitemap_noindex, sitemap_blocked and
  sitemap_missing against the fetched sitemap files and robots.txt
- canonical_missing and canonical_error, missing and duplicate titles and
  descriptions over the crawl
- h1_missing, h1_multiple, heading_skipped, heading_empty and
  h1_title_mismatch; content headings route to the record's tool
- the staging lock's noindex is never counted (Pitfall 12)
- TestCrawlerChecks on the planted site with a clean control page
2026-11-04 15:10:22 +01:00
Jakub Zych
c2b61bf8e9 feat: crawler finds a page with two H1 headings and stores the issue (tracer)
- golem15_seo_crawls, crawl pages, links, issues and ignores (P33-37)
- classes/crawl: fetchguard client on the site host and its www twin,
  sitemap index and robots.txt seeds, two workers with a delay, limits
- h1_missing and h1_multiple with the P33-38 fix route
- POST /_seo/api/v1/crawls queues the golem15.seo.crawl job; GET /issues
  answers the stored issues with id, severity, source, fix, found_at,
  ignored and crawl_id
2026-11-04 15:05:45 +01:00
Jakub Zych
d38049a956 docs: structured data, breadcrumbs, hreflang and pagination in README 2026-11-04 14:38:38 +01:00
Jakub Zych
10ff628f6a feat: structured data, graph preview and primary category in the SEO tab
- E8 golem15-seo-schema: the type per content kind, the Service grid with
  price and currency checks, custom JSON-LD with a 300 ms parse status
- E9 golem15-seo-graph: nodes and findings from the seo_graph action, the
  JSON, the Rich Results Test link, 800 ms re-check on fill changes, the
  ML locale switch, one queued request, the failure line
- golem15-seo-parent: the primary category select over the record's
  parents; the seoparent Decode accepts the loaded value sent back
- the rebuilt element bundle and vitest smoke tests
2026-11-04 14:37:49 +01:00
Jakub Zych
17da132b00 feat: graph validation and custom JSON-LD
- seo_schema (seoschema, E8) with the C17 server rules: invalid JSON with
  line and column, non-objects, 16 KB, depth 8, a literal <script and a
  type outside the kind's list refused; the value re-serialised
- a custom node with a generated @id merges into it (schema.merged save
  notice), other nodes are appended to the graph
- seo_primary_parent (seoparent) for article types; both values stored
  in the entry data on every locale row
- the seo_graph widget action (E9 data), P1 primary_gone and the P2
  schema type and breadcrumb trail rows
- GET /_seo/api/v1/graph and MCP seo_get_graph; seo_set_url_seo and
  apply_batch accept primary_parent and schema

The graph tool lands with the validation: the operations parity test
binds the seo_graph widget action to its tool.
2026-11-04 14:33:10 +01:00
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
df93c86001 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.
2026-11-04 13:47:12 +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
Jakub Zych
55f5e5dbb0 feat: plugin.yaml manifest for the SummerCMS marketplace
- plugin.yaml generated by summer plugin:manifest (name override: SEO)
- TestPluginManifest guards it with kiosk.CheckT
- require SummerCMS v0.6.0, the framework minimum in plugin.yaml
v0.1.1
2026-10-29 19:29:02 +01:00
ad419e9e6e fix: gofmt the filter test (WR-22) 2026-10-27 15:45:15 +01:00
5c2ad6aee1 fix: element tests drive the widget host protocol; the image element gets tests (WR-25)
The transfer element's busy and state=error handling is now tested
through the fake widget host, and golem15-seo-image gets tests for the
upload id matching, the error display, Remove and the disabled state.
The README's test gate runs npm test.
2026-10-27 15:44:22 +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
4ca26e9a89 fix: a URL rule's address is capped at 2048 bytes (WR-23)
Rule.Problems had no length check, so a long address reached the
(url, match) UNIQUE btree index and failed with a 500. It now refuses an
address over models.MaxRuleURL with rules.url_invalid, like redirect
sources.
2026-10-27 15:41:55 +01:00
7677d8d8af fix: the Open/Resolved and Chains filters apply an exact rule's slash, case and query options (WR-22)
coveredSQL and chainSQL compared the source literally, so /missing/ or
/missing?utm=x stayed open after /missing answered it, and /a -> /b/ was
no chain next to /b. Both now use one SQL form of the runtime's exact
match (prefiltered on the indexed source_key). Pattern and regex rules
remain with the per-row badge, which the list_not_found schema text now
says.
2026-10-27 15:41:18 +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
cab1b9aef4 fix: document that the frontend server must be a trusted proxy for the public buckets (WR-15)
The resolve and 404 buckets key on the client IP, which is the SSR
server's address unless it is in http.trusted_proxies and forwards
X-Forwarded-For; otherwise they cap the whole site at 600 requests a
minute. The README and the Buckets comment now say so. An authenticated,
unthrottled resolve call would need a new token and a framework way to
skip a bucket, so it is left for a decision.
2026-10-27 15:34:01 +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
3ecbd1f711 fix: the result preview and the robots.txt tester survive a dropped first action (CR-04)
Both elements sent their first summer-action from connectedCallback and
kept it in flight forever when the host dropped it (the v0.5.1 widget host
appends the element before it keeps a reference). A shared ActionQueue no
longer trusts that an emitted action was taken: one the host did not mark
busy synchronously is retried once on the next task, a second drop shows
an error, and a disconnect forgets the request in flight. The first
request is sent on a microtask. Element tests drive a fake host that
mirrors WidgetField's contract, including the append-first ordering, and
the robots.txt tester gets its own tests.
2026-10-27 15:24:33 +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
b19cd069a5 chore: require SummerCMS v0.5.1 v0.1.0 2026-10-27 14:02:14 +01:00
dcfbdc05ed test: element backstops
vitest + happy-dom tests of the UI-SPEC backstops: E1 status thresholds
(29/30 characters, 580/581 px; 119/120 and 920/921 for descriptions)
with a mocked measureText and the counter's wrap contract; the E2 grammar
over every directive combination, the emitted values and the stacking
contract with unclipped en and pl labels; the E3 effective title,
word-boundary truncation and keyphrase stem matching; each E4 lint rule
and a clean file; E6 apply gating. happy-dom has no layout engine, so the
narrow-width rows check the stylesheet's layout contract.
2026-10-27 13:26:59 +01:00
9461c74a80 fix: the result preview truncates at the last word boundary
UI-SPEC E-1: a title or description wider than its pixel budget is cut
at the last word boundary that fits, then " …" is appended. The preview
cut inside a word and appended "…" without a space. A first word too
wide on its own is still cut inside the word. Bundle rebuilt.
2026-10-27 13:23:20 +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
6f69031c73 feat: import and export element (E6)
- golem15-seo-transfer: export by dataset with a Blob download, the
  import file read in the browser and refused over 60 KB, the preview
  table (refused first, 200 rows, scrolling wrapper), Apply gated on
  refused and empty previews, a stale preview kept with the message
- en and pl copy; assets/dist rebuilt
2026-10-27 12:35:26 +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