TestAlternates also runs on a record shaped as the Journal descriptors
describe one: every enabled locale present, the untranslated one with
URL "" and the default locale's title, description and word count; it
answers no hreflang for that locale.
Crawl keys, the rendered comparison and the link graph use the decoded
path (classes.PathKey), the form the resolver, the descriptors and the
SEO entries use; the request URL is percent-encoded again
(classes.EscapeKeyPath). A non-ASCII address now matches its CMS record
and its stored links. A path that does not decode to text keeps its
encoded form.
Fetcher.Allowed and Key, the rendered comparison and the Chrome request
guard accept an address only on the site's effective port (explicit,
else 443 for https and 80 for http), so page code cannot make headless
Chrome reach other services on the site host's address and a link to
another port is not reported as a page of the site.
Render bounds opening the tab and rendering the page with one deadline
(TabOpenTimeout 5 s plus RenderTimeout 20 s), so a hung Chrome no longer
holds the crawl's render lock until the job timeout.
Cancelling a running crawl records cancel_requested_at and leaves the
row running until its job stops and writes cancelled, so no second
crawl starts and the row cannot be deleted or pruned meanwhile. The job
writes heartbeat_at every minute; a running crawl without a heartbeat
for 10 minutes is ended by the next start or delete.
The crawl job's context ends on a cancel or at the job timeout. Storing
the pages, links and issues now runs on a context detached from it with
its own 10-minute bound, a timeout ends the crawl failed with a
time-limit error, a LoadLimits failure falls back to the defaults, and a
fetch cut short by the cancellation is recorded as canceled.
The route report takes an optional noindex list of static paths. When
the key is present the stored set (golem15_seo_noindex_paths) is
replaced, and the sitemap filter leaves those paths out with the reason
noindex; a changed set asks the sitemap for a debounced rebuild through
contract.Rebuilder. A listed noindex address without a CMS record now
names sitemap_update_settings, or fix: code without the sitemap plugin.
- E8: the page and profile type options, an invalid currency
- E9: the checking line with aria-busy, noRules per type, one queued
request, the danger line and its recovery
- E10: both lists capped at 10
- the parent select's value parsing, empty default and disabled state
- schema: types per kind, @id links, every node kind, the publisher and
local business, every validator rule, custom JSON-LD caps and merge
- crawl: HTML parse, each check's positive and negative case, fix routes
with the fitting tool, the rendered comparison, allowRequest,
resolverRules and NewRenderer
- pagination, alternates and x-default, primary parents, the filter's
addresses, registry paths and fields, auto-link addresses
- GORM stamped created_at and updated_at in the process's local time,
which the timestamp columns keep as the wall clock; east of UTC a page
without a content date got a sitemap lastmod in the future
- Page.BeforeSave stamps both in UTC for the admin's and the API's saves,
and the admin's address change writes updated_at in UTC
- E10 golem15-seo-links in the SEO tab after the graph preview (types
with a body): inbound and outbound counts from the last finished crawl,
the orphan warning, up to 10 pages to link to (Copy address) and 10
that could link here
- classes/linkgraph.go builds the report from the crawl's pages and
links; the crawler's orphan suggestions share NormalText
- GET /_seo/api/v1/links and seo_get_link_suggestions; widget action
seo_links mapped in AdminOperations
- element bundle rebuilt
- SEO › Auto-links (golem15.seo.autolinks, after Pages): list by phrase
with locale, origin and active filters, the disabled row state, the
form with its refusals, history through a field-less extension
- toolbar Add focus keyphrases (from_keyphrases) and
POST /autolinks/from-keyphrases
- GET/POST /autolinks, PUT /autolinks/{id}, DELETE /autolinks and the
tools seo_list_autolinks, seo_create_autolink, seo_update_autolink,
seo_delete_autolinks, seo_autolinks_from_keyphrases
- the settings tile description and the permission comment name
auto-links and the site audit
- golem15_seo_autolinks (migration 202610280007) and the settings column
autolink_max (Links tab, 0 to 20, default 3)
- classes/autolinks.go: checked save and delete with history, ForPage
- ok resolve answers carry autolinks for the page's locale without
disabled phrases and links to the page itself, and autolink_max
- github.com/chromedp/chromedp v0.21.0 (decision note
2026-10-28-chromedp-rendered-crawl.md): with golem15.seo.crawl_chrome_path
the crawl renders up to golem15.seo.crawl_render_max pages (200) and
compares them with the raw HTML; without a path it records skipped
- guard: site host and www twin resolved and checked with
fetchguard.IsPrivateAddr, pinned through --host-resolver-rules with a
final MAP * ~NOTFOUND; Fetch-domain interception fails every request
outside the site (third-party scripts never load); fresh temporary
profile removed on Close; downloads denied; one tab, 20 s, 5 MiB DOM
- a relative or non-executable path, a private address or a Chrome that
does not start records render_status failed; the crawl still finishes
- config keys documented; README Rendered comparison section
- TestChromeRenderer (runs with a local Chromium) and TestRendererGuards
- the crawler: seeds, fetchguard limits, trusted mode, limits, cancel and
the crawls kept
- every check with its severity, the raw-HTML checks against the CMS, fix
routes, ignores, check again, staging and the nightly schedule
- the crawl and issue tools, Site audit, Crawls and the Site audit
settings tab, crawl_trusted, seo:crawl and the API reference
- Site audit tab (UI-SPEC C20): crawl_schedule, crawl_max_pages
(10-50 000), crawl_delay (100-5000 ms), crawl_keep (1-50) and
generic_anchors seeded with the en and pl lists; ranges refuse with
lagoon::validation.between on the screen and through the API
- the crawler reads its limits and generic anchors from the settings;
crawls beyond crawl_keep are deleted with their pages and issues
- seo:crawl [--scheduled] and the nightly schedule entry at 02:30
(P33-40): off does nothing, weekly runs on Mondays in app.timezone
- TestCrawlSchedule
- golem15.seo.issues (Site audit): current issues errors first, labels,
summary and fix columns, filters, P7 issue_details, Check this address
again, Ignore, Stop ignoring, bulk Ignore, ignored rows badged
- golem15.seo.crawls (Crawls): newest first, P6 crawl_summary with one
status callout, staging and rendered-comparison lines, stats strip and
per-kind table; Cancel; a running crawl refuses delete
- Start crawl toolbar action on both, refusing already_running and
no_site_url; navigation Site audit (gauge) and Crawls (globe)
- routes and MCP tools start/cancel/list/get/delete crawls, list_issues
with filters (open by default), recheck_url, ignore and unignore
- AdminOperations maps both controllers; toolbar actions get their own
operation kind
- TestCrawlAdmin, TestCrawlTools, TestIssueKindLabels
- orphan_page for listed indexable pages no crawled page links to, with
pages mentioning the title or focus keyphrase as suggestions (D-82)
- CMS-data image_alt and h1_in_content from the content descriptors
(TopHeading 2 only), stored with the crawl as source cms (D-85, D-50)
- seo-page names Tools edit seo_update_page; every stored issue carries
a tool or fix code with details.what (P33-38)
- TestIssueMerge (ignored fingerprints survive the next crawl, fixed
pages lose their issues) and TestStagingCrawl (Pitfall 12)
- jsonld_invalid, jsonld_multiple, and jsonld_required/recommended from
schema.Validate (the E9 rules); publisher nodes route to settings
- hreflang_error and hreflang_reciprocal over the fetched alternates
- broken_link and redirected_link with the linking pages, generic_anchor
against the en and pl seed lists
- image_alt, image_size and featured_lazy
- content_js check cms: the record title in the H1, half its words and
its primary parent's link in the raw HTML
- compareRendered (pure): links, an H1 or text only after JavaScript as
content_js check rendered; the Renderer seam and render bookkeeping
- TestRenderSkipped: skipped without a renderer, failed with its note
- 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
- 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
- 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
- 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.
- 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
- 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
- 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
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.
- 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
- 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
- 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
- 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
- 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
- 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)
- 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
- 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
(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.