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 //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 / 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 }