Files
sm-seo-plugin/classes/pagination.go
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

258 lines
7.4 KiB
Go

package classes
import (
"context"
"net/url"
"regexp"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// PageParam is the query parameter of the query form (?page=N).
const PageParam = "page"
// The placeholders of the page suffixes (D-41). %page% is filled by the
// resolver; %pages% is left for the layer, which knows the page count
// (P33-24).
const (
PlaceholderPage = "%page%"
PlaceholderPages = "%pages%"
)
// DefaultPageSegments are the path segments of the path form when the
// settings name none for a locale; other locales use "page".
var DefaultPageSegments = map[string]string{"pl": "strona", "en": "page", "de": "seite"}
// segmentPattern is a valid path segment (lowercase letters, digits,
// hyphens).
var segmentPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// ValidPageSegment reports whether s may be a page segment.
func ValidPageSegment(s string) bool { return segmentPattern.MatchString(s) }
// Pagination is the resolve answer's pagination block (P33-21), present
// only when the request carries a page: the site's form, the parameter of
// the query form, the request locale's segment of the path form, the page
// number (0 when the value is not a number) and whether the value is
// invalid (the layer answers 404).
type Pagination struct {
Form string `json:"form"`
Param string `json:"param"`
Segment string `json:"segment"`
Page int `json:"page"`
Invalid bool `json:"invalid"`
}
// PageConfig is a site's pagination form (D-40): query or path, and the
// path segment per locale.
type PageConfig struct {
Form string
Segments map[string]string
}
// Segment is the path segment of locale.
func (c PageConfig) Segment(locale string) string {
if s := c.Segments[locale]; s != "" {
return s
}
if s := DefaultPageSegments[locale]; s != "" {
return s
}
return "page"
}
// PageConfigOf reads the pagination form of the settings: the segment of
// each site locale is its translated setting when valid, else the default.
func (s *Service) PageConfigOf(ctx context.Context, db *gorm.DB, settings models.Settings) PageConfig {
cfg := PageConfig{Form: models.PageFormQuery, Segments: map[string]string{}}
if settings.PageForm == models.PageFormPath {
cfg.Form = models.PageFormPath
}
for _, locale := range s.Locales(ctx, db) {
seg := strings.TrimSpace(s.translatedSetting(ctx, db, &settings, "page_segment", settings.PageSegment, locale))
if !ValidPageSegment(seg) {
seg = DefaultPageSegments[locale]
if seg == "" {
seg = "page"
}
}
cfg.Segments[locale] = seg
}
return cfg
}
// pageRequest is what a URL says about its page.
type pageRequest struct {
// carried is true when the URL carries a page value.
carried bool
invalid bool
page int
// form and segment are the form (and path segment) the URL used.
form string
segment string
// base is the path without the path-form suffix; rest the query
// without the page parameter.
base string
rest url.Values
// exact is true when the number is written canonically (no leading
// zero, one value).
exact bool
}
// pageNumber reads a page value: digits only, at least 1.
func pageNumber(v string) (int, bool) {
if v == "" || len(v) > 9 {
return 0, false
}
for _, r := range v {
if r < '0' || r > '9' {
return 0, false
}
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return 0, false
}
return n, true
}
// parsePage reads the page of a normalised path and query in either form:
// ?page=N, or a trailing /<segment>/N with any locale's segment. A path
// suffix whose value is not a number counts only in the path form.
func parsePage(path string, query url.Values, cfg PageConfig) pageRequest {
pr := pageRequest{base: path, rest: url.Values{}, exact: true}
for k, vs := range query {
if k != PageParam {
pr.rest[k] = vs
}
}
if vs, ok := query[PageParam]; ok {
pr.carried, pr.form = true, models.PageFormQuery
v := ""
if len(vs) > 0 {
v = vs[0]
}
n, ok := pageNumber(v)
if !ok {
pr.invalid = true
return pr
}
pr.page, pr.exact = n, len(vs) == 1 && v == strconv.Itoa(n)
}
trimmed := strings.TrimSuffix(path, "/")
i := strings.LastIndex(trimmed, "/")
if i <= 0 {
return pr
}
head, value := trimmed[:i], trimmed[i+1:]
j := strings.LastIndex(head, "/")
if j < 0 {
return pr
}
prefix, segment := head[:j], head[j+1:]
known := false
for _, s := range cfg.Segments {
if s == segment {
known = true
}
}
if !known {
return pr
}
n, ok := pageNumber(value)
if !ok {
if cfg.Form == models.PageFormPath {
pr.carried, pr.invalid, pr.form, pr.segment = true, true, models.PageFormPath, segment
}
return pr
}
if prefix == "" {
prefix = "/"
}
exact := value == strconv.Itoa(n) && !pr.carried
pr.carried, pr.invalid, pr.form, pr.segment = true, false, models.PageFormPath, segment
pr.page, pr.base, pr.exact = n, prefix, exact
return pr
}
// pagedPath is base with the path-form suffix of page.
func pagedPath(base, segment string, page int) string {
b := strings.TrimSuffix(base, "/")
return b + "/" + segment + "/" + strconv.Itoa(page)
}
// pageLocation is the address of page of base in the site's form, with
// the other query parameters; page 1 (or none) is the address without a
// number (D-41, D-42). It is escaped for a Location.
func pageLocation(base string, rest url.Values, page int, cfg PageConfig, locale string, opts NormalizeOptions) string {
path := base
query := url.Values{}
for k, vs := range rest {
query[k] = vs
}
if page >= 2 {
if cfg.Form == models.PageFormPath {
path = pagedPath(base, cfg.Segment(locale), page)
if n, _ := Normalize(&url.URL{Path: path}, opts); n != nil {
path = n.Path
}
} else {
query.Set(PageParam, strconv.Itoa(page))
}
}
return joinQuery(escapePath(path), encodeQuery(query))
}
// pageAt is the page of a resolved URL (page 2 and later): its form, the
// segment of the path form and the number.
type pageAt struct {
form string
segment string
page int
}
// fillPage fills %page% of a suffix and keeps %pages%.
func fillPage(suffix string, page int) string {
return strings.ReplaceAll(suffix, PlaceholderPage, strconv.Itoa(page))
}
// pageSuffix is the title or description suffix in locale: the translated
// setting, else the default copy.
func (s *Service) pageSuffix(ctx context.Context, db *gorm.DB, settings models.Settings, locale string, title bool) string {
field, own, key := "page_description_suffix", settings.PageDescriptionSuffix, "golem15.seo::lang.settings.page_description_suffix_default"
if title {
field, own, key = "page_title_suffix", settings.PageTitleSuffix, "golem15.seo::lang.settings.page_title_suffix_default"
}
v := strings.TrimSpace(s.translatedSetting(ctx, db, &settings, field, own, locale))
if v == "" {
v = s.PhraseIn(locale, key, nil)
}
return v
}
// localeOfPath guesses the locale of a path without a record: the site
// locale whose /<code> prefix it carries, else the default locale.
func (s *Service) localeOfPath(ctx context.Context, db *gorm.DB, path string) string {
lower := strings.ToLower(path)
for _, l := range s.Locales(ctx, db) {
prefix := "/" + strings.ToLower(l)
if lower == prefix || strings.HasPrefix(lower, prefix+"/") {
return l
}
}
return s.DefaultLocale(ctx, db)
}
// localeOfTag is the site locale whose language tag is tag, else tag.
func (s *Service) localeOfTag(ctx context.Context, db *gorm.DB, tag string) string {
for _, l := range s.Locales(ctx, db) {
if LangTag(l) == tag {
return l
}
}
return tag
}