- 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
258 lines
7.4 KiB
Go
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
|
|
}
|