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
This commit is contained in:
Jakub Zych
2026-11-04 14:22:21 +01:00
parent fde5024b5a
commit 032d3dae45
12 changed files with 763 additions and 144 deletions

View File

@@ -51,8 +51,44 @@ const (
errHoursOrder = "golem15.seo::lang.settings.hours_order"
errCountryInvalid = "golem15.seo::lang.settings.country_invalid"
errCoordinateInvalid = "golem15.seo::lang.settings.coordinate_invalid"
errPageSegmentInvalid = "golem15.seo::lang.settings.page_segment_invalid"
)
// PagePlaceholders are the placeholders a page suffix may use (D-41).
var PagePlaceholders = []string{"%page%", "%pages%"}
// SuffixProblems refuses a page suffix with a placeholder other than
// %page% and %pages%.
func SuffixProblems(suffix string) []Problem {
for _, found := range placeholderPattern.FindAllString(suffix, -1) {
if !oneOfList(found, PagePlaceholders) {
return []Problem{{Key: errTemplateUnknown, Params: map[string]string{"placeholder": found}}}
}
}
return nil
}
// PaginationProblems lists the refusals of the Pagination tab: the form,
// a segment outside [a-z0-9-] and unknown suffix placeholders.
func (s Settings) PaginationProblems() map[string][]Problem {
out := map[string][]Problem{}
if s.PageForm != "" && !oneOfList(s.PageForm, []string{PageFormQuery, PageFormPath}) {
out["page_form"] = []Problem{{Key: errValueInvalid}}
}
if seg := strings.TrimSpace(s.PageSegment); seg != "" && !pageSegment.MatchString(seg) {
out["page_segment"] = []Problem{{Key: errPageSegmentInvalid}}
}
for field, v := range map[string]string{"page_title_suffix": s.PageTitleSuffix, "page_description_suffix": s.PageDescriptionSuffix} {
if p := SuffixProblems(v); p != nil {
out[field] = p
}
}
return out
}
// pageSegment is a valid page path segment.
var pageSegment = regexp.MustCompile(`^[a-z0-9-]+$`)
// LocalTypes are the LocalBusiness types the settings offer (UI-SPEC C20).
var LocalTypes = []string{
"LocalBusiness", "ProfessionalService", "Store", "FoodEstablishment",
@@ -200,7 +236,8 @@ func (Settings) Fillable() []string {
"publisher_phone", "publisher_same_as", "local_enabled", "local_type",
"local_price_range", "local_street", "local_postal_code", "local_city",
"local_region", "local_country", "local_latitude", "local_longitude",
"local_hours",
"local_hours", "page_form", "page_segment", "page_title_suffix",
"page_description_suffix",
}
}
@@ -304,6 +341,9 @@ func (s Settings) Problems() map[string][]Problem {
out[field] = p
}
}
for field, list := range s.PaginationProblems() {
out[field] = append(out[field], list...)
}
for field, list := range s.PublisherProblems() {
out[field] = append(out[field], list...)
}

View File

@@ -4,7 +4,7 @@
#
# SEO settings (UI-SPEC C11, C20): General, one tab per content type (added
# by the plugin's form extension after General), Indexing, Addresses,
# Links, Publisher, Local business and Verification.
# Pagination, Links, Publisher, Local business and Verification.
fields:
_staging:
@@ -146,6 +146,38 @@ fields:
default: false
tab: golem15.seo::lang.settings.tab_urls
page_form:
label: golem15.seo::lang.settings.page_form
comment: golem15.seo::lang.settings.page_form_comment
type: dropdown
span: left
default: query
options:
query: golem15.seo::lang.settings.page_form_query
path: golem15.seo::lang.settings.page_form_path
tab: golem15.seo::lang.settings.tab_pagination
page_segment:
label: golem15.seo::lang.settings.page_segment
comment: golem15.seo::lang.settings.page_segment_comment
type: mltext
span: right
tab: golem15.seo::lang.settings.tab_pagination
page_title_suffix:
label: golem15.seo::lang.settings.page_title_suffix
comment: golem15.seo::lang.settings.page_title_suffix_comment
type: mltext
span: left
tab: golem15.seo::lang.settings.tab_pagination
page_description_suffix:
label: golem15.seo::lang.settings.page_description_suffix
comment: golem15.seo::lang.settings.page_description_suffix_comment
type: mltext
span: right
tab: golem15.seo::lang.settings.tab_pagination
links_new_tab:
label: golem15.seo::lang.settings.links_new_tab
comment: golem15.seo::lang.settings.links_new_tab_comment