From 032d3dae453664609eaf8a78d810aa3dd9102ab3 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 14:22:21 +0100 Subject: [PATCH] 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, //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 --- classes/pagination.go | 257 ++++++++++++++++++++++++++++++++++++ classes/resolver.go | 147 +++++++++++++++++---- classes/settings_write.go | 157 +++++++++++----------- classes/site.go | 29 +++- classes/sitemap_filter.go | 2 +- controllers/api/settings.go | 86 ++++++------ lang/en/lang.yaml | 14 ++ lang/pl/lang.yaml | 14 ++ models/settings.go | 42 +++++- models/settings/fields.yaml | 34 ++++- settings_test.go | 4 +- structured_data_test.go | 121 +++++++++++++++++ 12 files changed, 763 insertions(+), 144 deletions(-) create mode 100644 classes/pagination.go diff --git a/classes/pagination.go b/classes/pagination.go new file mode 100644 index 0000000..7f14ced --- /dev/null +++ b/classes/pagination.go @@ -0,0 +1,257 @@ +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 +} diff --git a/classes/resolver.go b/classes/resolver.go index 9dfa9af..4025fbb 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -5,6 +5,7 @@ import ( "errors" "net/url" "sort" + "strconv" "strings" "unicode/utf8" @@ -70,11 +71,15 @@ type Result struct { // list, the page itself and x-default included (D-55 to D-57); JSONLD // its one @graph (D-43); Breadcrumbs its trail, the same items as the // BreadcrumbList node (D-86). All are set on ok answers only. - HTMLLang string `json:"html_lang,omitempty"` - Alternates []Alternate `json:"alternates,omitempty"` - JSONLD map[string]any `json:"jsonld,omitempty"` - Breadcrumbs []schema.Crumb `json:"breadcrumbs,omitempty"` - Sources map[string]string `json:"-"` + HTMLLang string `json:"html_lang,omitempty"` + Alternates []Alternate `json:"alternates,omitempty"` + JSONLD map[string]any `json:"jsonld,omitempty"` + Breadcrumbs []schema.Crumb `json:"breadcrumbs,omitempty"` + // Pagination is present when the request carries a page (D-40 to + // D-42): a not_found with invalid true for a page value that is not a + // page number. + Pagination *Pagination `json:"pagination,omitempty"` + Sources map[string]string `json:"-"` // TypeKey and RecordID name the record behind the URL, "" when none. TypeKey string `json:"-"` RecordID string `json:"-"` @@ -442,6 +447,24 @@ func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) { } npath, nquery := norm.Path, norm.RawQuery + // Pagination (D-40 to D-42): the page of the URL in either form. A + // path-form suffix on a path a record owns is not a page. + pages := svc.PageConfigOf(ctx, db, settings) + nvalues, _ := url.ParseQuery(nquery) + pr := parsePage(npath, nvalues, pages) + if pr.carried && pr.form == models.PageFormPath { + if owner, err := r.Strict(ctx, npath, opts); err != nil { + return Result{}, err + } else if owner.Key != "" { + pr = pageRequest{base: npath} + } + } + if pr.invalid { + res := Result{Action: ActionNotFound, Status: 404, URL: self, Staging: svc.Staging(), Sources: map[string]string{}} + res.Pagination = &Pagination{Form: pages.Form, Param: PageParam, Segment: pr.segment, Invalid: true} + return res, nil + } + // 2. Redirects on the normalised path. set, err := LoadRedirectSet(db) if err != nil { @@ -472,23 +495,67 @@ func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) { return res, nil } - // 3. Strict URLs. - st, err := r.Strict(ctx, npath, opts) + // 3. Strict URLs, on the path without its page. + base := npath + if pr.carried { + base = pr.base + } + st, err := r.Strict(ctx, base, opts) if err != nil { return Result{}, err } + move := changed if st.Key != "" && st.Mismatch { if st.Canonical == "" { return Result{Action: ActionNotFound, Status: 404, URL: self, Staging: svc.Staging(), Sources: map[string]string{}}, nil } - return r.moved(self, origin+joinQuery(escapePath(st.Canonical), nquery)), nil + base, move = st.Canonical, true } - if changed { - return r.moved(self, origin+joinQuery(escapePath(npath), nquery)), nil + if !pr.carried { + if move { + return r.moved(self, origin+joinQuery(escapePath(base), nquery)), nil + } + // 4. The effective SEO. + return r.resolveAt(ctx, db, settings, npath, query, nquery, st.Key, st.Match, st.Record, false, nil) } - // 4. The effective SEO. - return r.resolveAt(ctx, db, settings, npath, query, nquery, st.Key, st.Match, st.Record, false) + // A page: page 1 and the other form 301 to the configured address, + // together with normalisation and strict URLs in one hop. + locale := "" + if st.Key != "" && st.Match != nil { + locale = st.Match.Locale + } else { + locale = svc.localeOfPath(ctx, db, base) + } + segment := pages.Segment(locale) + canonical := pr.page >= 2 && pr.exact && pr.form == pages.Form && (pr.form == models.PageFormQuery || pr.segment == segment) + if move || !canonical { + return r.moved(self, origin+pageLocation(base, pr.rest, pr.page, pages, locale, opts)), nil + } + pg := &pageAt{form: pages.Form, segment: segment, page: pr.page} + res, err := r.resolveAt(ctx, db, settings, base, query, nquery, st.Key, st.Match, st.Record, false, pg) + if err != nil { + return Result{}, err + } + // The hreflang alternates of a page point at the same page of each + // translation. + def := svc.DefaultLocale(ctx, db) + for i, a := range res.Alternates { + u, err := url.Parse(a.URL) + if err != nil || !strings.HasPrefix(a.URL, site) { + continue + } + code := def + if a.Hreflang != "x-default" { + code = svc.localeOfTag(ctx, db, a.Hreflang) + } + res.Alternates[i].URL = site + pageLocation(u.Path, url.Values{}, pr.page, pages, code, opts) + } + res.Pagination = &Pagination{Form: pages.Form, Param: PageParam, Segment: segment, Page: pr.page} + if pages.Form == models.PageFormQuery { + res.Pagination.Segment = "" + } + return res, nil } // moved is a 301 answer to location. @@ -535,17 +602,29 @@ func (r *Resolver) ResolveRecord(ctx context.Context, key, id, locale string) (R if err != nil { return Result{}, false, err } - res, err := r.resolveAt(ctx, db, settings, target, query, rawQueryOf(path), key, &pact.ContentMatchResult{ID: id, Locale: locale, Exact: true}, rec, true) + res, err := r.resolveAt(ctx, db, settings, target, query, rawQueryOf(path), key, &pact.ContentMatchResult{ID: id, Locale: locale, Exact: true}, rec, true, nil) return res, err == nil, err } // resolveAt computes the answer for a path whose owner lookup is done: -// key, match and rec name the record behind it (key "" when none). -func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.Settings, path string, query url.Values, rawQuery, key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, force bool) (Result, error) { +// key, match and rec name the record behind it (key "" when none). pg is +// the page of a paginated address (page 2 and later), nil otherwise: the +// page stays in the URL and the self-referencing canonical, and the title +// and description get the page suffixes (D-21, D-41). +func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.Settings, path string, query url.Values, rawQuery, key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, force bool, pg *pageAt) (Result, error) { svc := r.Service site := svc.SiteURL() kept, filtered := splitQuery(settings.Params(), path, query) - self := site + path + selfPath := path + if pg != nil { + if pg.form == models.PageFormPath { + selfPath = pagedPath(path, pg.segment, pg.page) + kept.Del(PageParam) + } else { + kept.Set(PageParam, strconv.Itoa(pg.page)) + } + } + self := site + selfPath if q := encodeQuery(kept); q != "" { self += "?" + q } @@ -590,15 +669,18 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S // the content title > content title > site name. The home page without // a record uses the home template. var titleTemplate candidate + titleTpl := "" switch { case key != "": - tpl := typeSetting.TitleTemplate - if strings.TrimSpace(tpl) == "" { - tpl = settings.TitleTemplate + titleTpl = typeSetting.TitleTemplate + if strings.TrimSpace(titleTpl) == "" { + titleTpl = settings.TitleTemplate } - titleTemplate = candidate{RenderTemplate(tpl, vars), SourceTemplate} case home: - titleTemplate = candidate{RenderTemplate(settings.HomeTemplate, vars), SourceTemplate} + titleTpl = settings.HomeTemplate + } + if titleTpl != "" { + titleTemplate = candidate{RenderTemplate(titleTpl, vars), SourceTemplate} } seo.Title, src["title"] = pick( candidate{ruleValue(rules, "title"), SourceRule}, @@ -621,12 +703,31 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S candidate{defaultDescription, SourceGlobal}, ) - // Canonical: rule > record > self. + // Page 2 and later (D-41): the title suffix goes after the page title, + // inside the template when the title comes from one, and the + // description suffix at the end; %page% is filled, %pages% is left for + // the layer (P33-24). + if pg != nil { + suffix := fillPage(svc.pageSuffix(ctx, db, settings, locale, true), pg.page) + if src["title"] == SourceTemplate && strings.Contains(titleTpl, PlaceholderTitle) { + paged := vars + paged.Title = strings.TrimSpace(paged.Title + " " + suffix) + seo.Title = RenderTemplate(titleTpl, paged) + } else { + seo.Title = strings.TrimSpace(seo.Title + " " + suffix) + } + if desc := fillPage(svc.pageSuffix(ctx, db, settings, locale, false), pg.page); desc != "" { + seo.Description = strings.TrimSpace(seo.Description + " " + desc) + } + } + + // Canonical: rule > record > self; page 2 and later is its own + // canonical (D-21). canonical, canonicalSource := pick( candidate{ruleValue(rules, "canonical"), SourceRule}, candidate{entry.Canonical, SourceRecord}, ) - if canonical == "" { + if canonical == "" || pg != nil { canonical, canonicalSource = self, SourceDefault } else { canonical = AbsoluteURL(site, canonical) diff --git a/classes/settings_write.go b/classes/settings_write.go index 5a530d2..85fdd86 100644 --- a/classes/settings_write.go +++ b/classes/settings_write.go @@ -44,45 +44,50 @@ var SettingsColumns = []string{ "publisher_email", "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_latitude", "local_longitude", "local_hours", "page_form", + "page_segment", "page_title_suffix", "page_description_suffix", } // settingsText maps the plain text settings columns to their fields. func settingsText(st *models.Settings) map[string]*string { return map[string]*string{ - "site_name": &st.SiteName, - "twitter_site": &st.TwitterSite, - "title_template": &st.TitleTemplate, - "home_template": &st.HomeTemplate, - "default_description": &st.DefaultDescription, - "default_og_image": &st.DefaultOGImage, - "noindex_paths": &st.NoindexPaths, - "search_paths": &st.SearchPaths, - "url_host": &st.URLHost, - "url_slash": &st.URLSlash, - "verify_google": &st.VerifyGoogle, - "verify_bing": &st.VerifyBing, - "verify_yandex": &st.VerifyYandex, - "csv_delimiter": &st.CSVDelimiter, - "nofollow_domains": &st.NofollowDomains, - "sponsored_domains": &st.SponsoredDomains, - "breadcrumb_home": &st.BreadcrumbHome, - "publisher_type": &st.PublisherType, - "publisher_name": &st.PublisherName, - "publisher_logo": &st.PublisherLogo, - "publisher_url": &st.PublisherURL, - "publisher_email": &st.PublisherEmail, - "publisher_phone": &st.PublisherPhone, - "publisher_same_as": &st.PublisherSameAs, - "local_type": &st.LocalType, - "local_price_range": &st.LocalPriceRange, - "local_street": &st.LocalStreet, - "local_postal_code": &st.LocalPostalCode, - "local_city": &st.LocalCity, - "local_region": &st.LocalRegion, - "local_country": &st.LocalCountry, - "local_latitude": &st.LocalLatitude, - "local_longitude": &st.LocalLongitude, + "site_name": &st.SiteName, + "twitter_site": &st.TwitterSite, + "title_template": &st.TitleTemplate, + "home_template": &st.HomeTemplate, + "default_description": &st.DefaultDescription, + "default_og_image": &st.DefaultOGImage, + "noindex_paths": &st.NoindexPaths, + "search_paths": &st.SearchPaths, + "url_host": &st.URLHost, + "url_slash": &st.URLSlash, + "verify_google": &st.VerifyGoogle, + "verify_bing": &st.VerifyBing, + "verify_yandex": &st.VerifyYandex, + "csv_delimiter": &st.CSVDelimiter, + "nofollow_domains": &st.NofollowDomains, + "sponsored_domains": &st.SponsoredDomains, + "breadcrumb_home": &st.BreadcrumbHome, + "publisher_type": &st.PublisherType, + "publisher_name": &st.PublisherName, + "publisher_logo": &st.PublisherLogo, + "publisher_url": &st.PublisherURL, + "publisher_email": &st.PublisherEmail, + "publisher_phone": &st.PublisherPhone, + "publisher_same_as": &st.PublisherSameAs, + "local_type": &st.LocalType, + "local_price_range": &st.LocalPriceRange, + "local_street": &st.LocalStreet, + "local_postal_code": &st.LocalPostalCode, + "local_city": &st.LocalCity, + "local_region": &st.LocalRegion, + "local_country": &st.LocalCountry, + "local_latitude": &st.LocalLatitude, + "local_longitude": &st.LocalLongitude, + "page_form": &st.PageForm, + "page_segment": &st.PageSegment, + "page_title_suffix": &st.PageTitleSuffix, + "page_description_suffix": &st.PageDescriptionSuffix, } } @@ -90,46 +95,50 @@ func settingsText(st *models.Settings) map[string]*string { // history and the API. func SettingsValues(st models.Settings) map[string]any { return map[string]any{ - "site_name": st.SiteName, - "twitter_site": st.TwitterSite, - "title_template": st.TitleTemplate, - "home_template": st.HomeTemplate, - "default_description": st.DefaultDescription, - "default_og_image": st.DefaultOGImage, - "query_params": plainValue(st.Params()), - "noindex_paths": st.NoindexPaths, - "search_paths": st.SearchPaths, - "url_host": st.URLHost, - "url_slash": st.URLSlash, - "url_lowercase": st.URLLowercase, - "links_new_tab": st.LinksNewTab, - "nofollow_domains": st.NofollowDomains, - "sponsored_domains": st.SponsoredDomains, - "verify_google": st.VerifyGoogle, - "verify_bing": st.VerifyBing, - "verify_yandex": st.VerifyYandex, - "verify_other": plainValue(st.OtherMeta()), - "verify_files": plainValue(st.Files()), - "csv_delimiter": st.CSVDelimiter, - "breadcrumb_home": st.BreadcrumbHome, - "publisher_type": st.PublisherType, - "publisher_name": st.PublisherName, - "publisher_logo": st.PublisherLogo, - "publisher_url": st.PublisherURL, - "publisher_email": st.PublisherEmail, - "publisher_phone": st.PublisherPhone, - "publisher_same_as": st.PublisherSameAs, - "local_enabled": st.LocalEnabled, - "local_type": st.LocalType, - "local_price_range": st.LocalPriceRange, - "local_street": st.LocalStreet, - "local_postal_code": st.LocalPostalCode, - "local_city": st.LocalCity, - "local_region": st.LocalRegion, - "local_country": st.LocalCountry, - "local_latitude": st.LocalLatitude, - "local_longitude": st.LocalLongitude, - "local_hours": plainValue(st.Hours()), + "site_name": st.SiteName, + "twitter_site": st.TwitterSite, + "title_template": st.TitleTemplate, + "home_template": st.HomeTemplate, + "default_description": st.DefaultDescription, + "default_og_image": st.DefaultOGImage, + "query_params": plainValue(st.Params()), + "noindex_paths": st.NoindexPaths, + "search_paths": st.SearchPaths, + "url_host": st.URLHost, + "url_slash": st.URLSlash, + "url_lowercase": st.URLLowercase, + "links_new_tab": st.LinksNewTab, + "nofollow_domains": st.NofollowDomains, + "sponsored_domains": st.SponsoredDomains, + "verify_google": st.VerifyGoogle, + "verify_bing": st.VerifyBing, + "verify_yandex": st.VerifyYandex, + "verify_other": plainValue(st.OtherMeta()), + "verify_files": plainValue(st.Files()), + "csv_delimiter": st.CSVDelimiter, + "breadcrumb_home": st.BreadcrumbHome, + "publisher_type": st.PublisherType, + "publisher_name": st.PublisherName, + "publisher_logo": st.PublisherLogo, + "publisher_url": st.PublisherURL, + "publisher_email": st.PublisherEmail, + "publisher_phone": st.PublisherPhone, + "publisher_same_as": st.PublisherSameAs, + "local_enabled": st.LocalEnabled, + "local_type": st.LocalType, + "local_price_range": st.LocalPriceRange, + "local_street": st.LocalStreet, + "local_postal_code": st.LocalPostalCode, + "local_city": st.LocalCity, + "local_region": st.LocalRegion, + "local_country": st.LocalCountry, + "local_latitude": st.LocalLatitude, + "local_longitude": st.LocalLongitude, + "local_hours": plainValue(st.Hours()), + "page_form": st.PageForm, + "page_segment": st.PageSegment, + "page_title_suffix": st.PageTitleSuffix, + "page_description_suffix": st.PageDescriptionSuffix, } } diff --git a/classes/site.go b/classes/site.go index ecbd146..10c6589 100644 --- a/classes/site.go +++ b/classes/site.go @@ -15,7 +15,8 @@ import ( // sitemap settings; Routes each content type's layered URL pattern for // the layer's drift check; RootFiles the file names the layer serves at // the site root (verification files, the IndexNow key file); LLMS whether -// /llms.txt is served. +// /llms.txt is served; Pagination the site's page address form and +// HomeCrumb the home crumb (33-06, D-40, D-86). type SiteAnswer struct { SiteURL string `json:"site_url"` DefaultLocale string `json:"default_locale"` @@ -26,6 +27,25 @@ type SiteAnswer struct { Routes []SiteRoute `json:"routes"` RootFiles []string `json:"root_files"` LLMS bool `json:"llms"` + Pagination SitePagination `json:"pagination"` + HomeCrumb SiteHomeCrumb `json:"home_crumb"` +} + +// SitePagination is the site answer's pagination block: the form (query +// or path), the query parameter and the path segment per locale, so the +// layer builds numbered links and checks drift. +type SitePagination struct { + Form string `json:"form"` + Param string `json:"param"` + Segments map[string]string `json:"segments"` +} + +// SiteHomeCrumb is the home crumb: its name in the default locale, the +// home URL and the name per locale. +type SiteHomeCrumb struct { + Name string `json:"name"` + URL string `json:"url"` + Names map[string]string `json:"names"` } // SiteLinks is the site answer's links block. @@ -86,6 +106,13 @@ func (s *Service) Site(ctx context.Context) (SiteAnswer, error) { out.Locales = s.Locales(ctx, db) out.Links.NewTab = settings.LinksNewTab out.LLMS = robots.LlmsEnabled + pages := s.PageConfigOf(ctx, db, settings) + out.Pagination = SitePagination{Form: pages.Form, Param: PageParam, Segments: pages.Segments} + out.HomeCrumb = SiteHomeCrumb{URL: out.SiteURL + "/", Names: map[string]string{}} + for _, locale := range out.Locales { + out.HomeCrumb.Names[locale] = s.HomeCrumbName(ctx, db, settings, locale) + } + out.HomeCrumb.Name = out.HomeCrumb.Names[out.DefaultLocale] if reg := s.Registry(); reg != nil { for _, typ := range reg.ContentTypes() { if typ.Key == PageTypeKey { diff --git a/classes/sitemap_filter.go b/classes/sitemap_filter.go index 89eb6d9..19c20ed 100644 --- a/classes/sitemap_filter.go +++ b/classes/sitemap_filter.go @@ -166,7 +166,7 @@ func (b *filterBatch) decideTarget(ctx context.Context, c contract.Candidate) (c match = &pact.ContentMatchResult{ID: c.RecordID, Locale: c.Locale, Exact: true} } } - res, err := b.resolver.resolveAt(ctx, b.db, b.settings, path, query, rawQuery, key, match, nil, true) + res, err := b.resolver.resolveAt(ctx, b.db, b.settings, path, query, rawQuery, key, match, nil, true, nil) if err != nil { return contract.Decision{}, "", err } diff --git a/controllers/api/settings.go b/controllers/api/settings.go index b88cb3e..c75acae 100644 --- a/controllers/api/settings.go +++ b/controllers/api/settings.go @@ -13,47 +13,51 @@ import ( // UpdateSettingsInput is the body of PUT /_seo/api/v1/settings: only the // fields sent change. type UpdateSettingsInput struct { - SiteName *string `json:"site_name,omitempty" jsonschema:"the site name (%site% in templates)"` - TwitterSite *string `json:"twitter_site,omitempty" jsonschema:"the site's X/Twitter handle"` - TitleTemplate *string `json:"title_template,omitempty" jsonschema:"the title template: %title%, %description% and %site%"` - HomeTemplate *string `json:"home_template,omitempty" jsonschema:"the home page title template"` - DefaultDescription *string `json:"default_description,omitempty" jsonschema:"the description of pages without one"` - DefaultOGImage *string `json:"default_og_image,omitempty" jsonschema:"the social image of pages without one"` - QueryParams *[]models.QueryParam `json:"query_params,omitempty" jsonschema:"the query parameter list: name (a trailing * matches a prefix), kind content, filter or tracking, and an optional path pattern"` - NoindexPaths *string `json:"noindex_paths,omitempty" jsonschema:"path patterns never indexed, one per line"` - SearchPaths *string `json:"search_paths,omitempty" jsonschema:"search result path patterns (noindex,follow), one per line"` - URLHost *string `json:"url_host,omitempty" jsonschema:"keep, www or bare"` - URLSlash *string `json:"url_slash,omitempty" jsonschema:"keep, add or remove a trailing slash"` - URLLowercase *bool `json:"url_lowercase,omitempty" jsonschema:"lowercase the ASCII letters of addresses"` - LinksNewTab *bool `json:"links_new_tab,omitempty" jsonschema:"open links to other sites in a new tab (rel=noopener, announced to screen readers); default true"` - NofollowDomains *string `json:"nofollow_domains,omitempty" jsonschema:"domains whose links always get nofollow, one per line, e.g. example.com (subdomains included; no https:// and no path)"` - SponsoredDomains *string `json:"sponsored_domains,omitempty" jsonschema:"domains whose links always get sponsored, one per line (subdomains included)"` - VerifyGoogle *string `json:"verify_google,omitempty" jsonschema:"the Google Search Console verification code"` - VerifyBing *string `json:"verify_bing,omitempty" jsonschema:"the Bing verification code"` - VerifyYandex *string `json:"verify_yandex,omitempty" jsonschema:"the Yandex verification code"` - VerifyOther *[]models.VerifyMeta `json:"verify_other,omitempty" jsonschema:"other verification meta tags (name, content)"` - VerifyFiles *[]models.VerifyFile `json:"verify_files,omitempty" jsonschema:"verification files served from the site root (filename, body)"` - CSVDelimiter *string `json:"csv_delimiter,omitempty" jsonschema:"the import and export delimiter: , or ;"` - BreadcrumbHome *string `json:"breadcrumb_home,omitempty" jsonschema:"the home page name in breadcrumb trails and BreadcrumbList (default locale); empty: Home / Strona główna"` - PublisherType *string `json:"publisher_type,omitempty" jsonschema:"who publishes the site in the JSON-LD of every page: organization or person"` - PublisherName *string `json:"publisher_name,omitempty" jsonschema:"the publisher name (default locale); empty: the site name"` - PublisherLogo *string `json:"publisher_logo,omitempty" jsonschema:"the organisation logo (at least 112x112 px) or the person's photo: a URL or a site path"` - PublisherURL *string `json:"publisher_url,omitempty" jsonschema:"the publisher website, an https URL; empty: the site address"` - PublisherEmail *string `json:"publisher_email,omitempty" jsonschema:"the publisher contact email"` - PublisherPhone *string `json:"publisher_phone,omitempty" jsonschema:"the publisher phone with the country code, e.g. +48 600 000 000"` - PublisherSameAs *string `json:"publisher_same_as,omitempty" jsonschema:"profiles elsewhere (sameAs), one https URL per line"` - LocalEnabled *bool `json:"local_enabled,omitempty" jsonschema:"publish the organisation as a local business (not for a person)"` - LocalType *string `json:"local_type,omitempty" jsonschema:"the local business type: LocalBusiness, ProfessionalService, Store, FoodEstablishment, HealthAndBeautyBusiness, MedicalBusiness, LegalService, HomeAndConstructionBusiness, AutomotiveBusiness or FinancialService"` - LocalPriceRange *string `json:"local_price_range,omitempty" jsonschema:"the price range, e.g. 50–200 zł or $$"` - LocalStreet *string `json:"local_street,omitempty" jsonschema:"street and number (required for a local business)"` - LocalPostalCode *string `json:"local_postal_code,omitempty" jsonschema:"postal code (required for a local business)"` - LocalCity *string `json:"local_city,omitempty" jsonschema:"city (required for a local business)"` - LocalRegion *string `json:"local_region,omitempty" jsonschema:"region"` - LocalCountry *string `json:"local_country,omitempty" jsonschema:"two-letter country code, e.g. PL"` - LocalLatitude *string `json:"local_latitude,omitempty" jsonschema:"latitude in decimal degrees (-90 to 90)"` - LocalLongitude *string `json:"local_longitude,omitempty" jsonschema:"longitude in decimal degrees (-180 to 180)"` - LocalHours *[]models.OpeningHours `json:"local_hours,omitempty" jsonschema:"opening hours rows: day (mon … sun), opens and closes in 24-hour HH:MM; two rows split a day, a day without rows is closed"` - Types map[string]classes.TypeSettingWrite `json:"types,omitempty" jsonschema:"per content type key: inject (show the SEO tab), title_template, description_template, robots"` + SiteName *string `json:"site_name,omitempty" jsonschema:"the site name (%site% in templates)"` + TwitterSite *string `json:"twitter_site,omitempty" jsonschema:"the site's X/Twitter handle"` + TitleTemplate *string `json:"title_template,omitempty" jsonschema:"the title template: %title%, %description% and %site%"` + HomeTemplate *string `json:"home_template,omitempty" jsonschema:"the home page title template"` + DefaultDescription *string `json:"default_description,omitempty" jsonschema:"the description of pages without one"` + DefaultOGImage *string `json:"default_og_image,omitempty" jsonschema:"the social image of pages without one"` + QueryParams *[]models.QueryParam `json:"query_params,omitempty" jsonschema:"the query parameter list: name (a trailing * matches a prefix), kind content, filter or tracking, and an optional path pattern"` + NoindexPaths *string `json:"noindex_paths,omitempty" jsonschema:"path patterns never indexed, one per line"` + SearchPaths *string `json:"search_paths,omitempty" jsonschema:"search result path patterns (noindex,follow), one per line"` + URLHost *string `json:"url_host,omitempty" jsonschema:"keep, www or bare"` + URLSlash *string `json:"url_slash,omitempty" jsonschema:"keep, add or remove a trailing slash"` + URLLowercase *bool `json:"url_lowercase,omitempty" jsonschema:"lowercase the ASCII letters of addresses"` + LinksNewTab *bool `json:"links_new_tab,omitempty" jsonschema:"open links to other sites in a new tab (rel=noopener, announced to screen readers); default true"` + NofollowDomains *string `json:"nofollow_domains,omitempty" jsonschema:"domains whose links always get nofollow, one per line, e.g. example.com (subdomains included; no https:// and no path)"` + SponsoredDomains *string `json:"sponsored_domains,omitempty" jsonschema:"domains whose links always get sponsored, one per line (subdomains included)"` + VerifyGoogle *string `json:"verify_google,omitempty" jsonschema:"the Google Search Console verification code"` + VerifyBing *string `json:"verify_bing,omitempty" jsonschema:"the Bing verification code"` + VerifyYandex *string `json:"verify_yandex,omitempty" jsonschema:"the Yandex verification code"` + VerifyOther *[]models.VerifyMeta `json:"verify_other,omitempty" jsonschema:"other verification meta tags (name, content)"` + VerifyFiles *[]models.VerifyFile `json:"verify_files,omitempty" jsonschema:"verification files served from the site root (filename, body)"` + CSVDelimiter *string `json:"csv_delimiter,omitempty" jsonschema:"the import and export delimiter: , or ;"` + BreadcrumbHome *string `json:"breadcrumb_home,omitempty" jsonschema:"the home page name in breadcrumb trails and BreadcrumbList (default locale); empty: Home / Strona główna"` + PublisherType *string `json:"publisher_type,omitempty" jsonschema:"who publishes the site in the JSON-LD of every page: organization or person"` + PublisherName *string `json:"publisher_name,omitempty" jsonschema:"the publisher name (default locale); empty: the site name"` + PublisherLogo *string `json:"publisher_logo,omitempty" jsonschema:"the organisation logo (at least 112x112 px) or the person's photo: a URL or a site path"` + PublisherURL *string `json:"publisher_url,omitempty" jsonschema:"the publisher website, an https URL; empty: the site address"` + PublisherEmail *string `json:"publisher_email,omitempty" jsonschema:"the publisher contact email"` + PublisherPhone *string `json:"publisher_phone,omitempty" jsonschema:"the publisher phone with the country code, e.g. +48 600 000 000"` + PublisherSameAs *string `json:"publisher_same_as,omitempty" jsonschema:"profiles elsewhere (sameAs), one https URL per line"` + LocalEnabled *bool `json:"local_enabled,omitempty" jsonschema:"publish the organisation as a local business (not for a person)"` + LocalType *string `json:"local_type,omitempty" jsonschema:"the local business type: LocalBusiness, ProfessionalService, Store, FoodEstablishment, HealthAndBeautyBusiness, MedicalBusiness, LegalService, HomeAndConstructionBusiness, AutomotiveBusiness or FinancialService"` + LocalPriceRange *string `json:"local_price_range,omitempty" jsonschema:"the price range, e.g. 50–200 zł or $$"` + LocalStreet *string `json:"local_street,omitempty" jsonschema:"street and number (required for a local business)"` + LocalPostalCode *string `json:"local_postal_code,omitempty" jsonschema:"postal code (required for a local business)"` + LocalCity *string `json:"local_city,omitempty" jsonschema:"city (required for a local business)"` + LocalRegion *string `json:"local_region,omitempty" jsonschema:"region"` + LocalCountry *string `json:"local_country,omitempty" jsonschema:"two-letter country code, e.g. PL"` + LocalLatitude *string `json:"local_latitude,omitempty" jsonschema:"latitude in decimal degrees (-90 to 90)"` + LocalLongitude *string `json:"local_longitude,omitempty" jsonschema:"longitude in decimal degrees (-180 to 180)"` + PageForm *string `json:"page_form,omitempty" jsonschema:"the paginated address form: query (/blog?page=2) or path (/blog//2); the other form, ?page=1 and //1 answer 301"` + PageSegment *string `json:"page_segment,omitempty" jsonschema:"the path form's segment in the default locale, lowercase letters, digits and hyphens (default strona for pl, page for en)"` + PageTitleSuffix *string `json:"page_title_suffix,omitempty" jsonschema:"added after the page title on page 2 and later (default locale); placeholders %page% and %pages%"` + PageDescriptionSuffix *string `json:"page_description_suffix,omitempty" jsonschema:"added at the end of the meta description on page 2 and later (default locale); placeholders %page% and %pages%"` + LocalHours *[]models.OpeningHours `json:"local_hours,omitempty" jsonschema:"opening hours rows: day (mon … sun), opens and closes in 24-hour HH:MM; two rows split a day, a day without rows is closed"` + Types map[string]classes.TypeSettingWrite `json:"types,omitempty" jsonschema:"per content type key: inject (show the SEO tab), title_template, description_template, robots"` } // settingsJSON is the settings in an answer: the values and, per content diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index ad6bbdb..c83efe6 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -216,6 +216,20 @@ settings: hours_order: "Closing must come after opening." country_invalid: "Use a two-letter country code, e.g. PL." coordinate_invalid: "Enter decimal degrees: latitude from -90 to 90, longitude from -180 to 180." + tab_pagination: "Pagination" + page_form: "Page address form" + page_form_query: "Query parameter: /blog?page=2" + page_form_path: "Path segment: /blog/strona/2" + page_form_comment: "A change sends every address in the other form to this one with a 301. ?page=1 always redirects to the address without a number." + page_segment: "Path segment" + page_segment_comment: "Used with the path form, per language, e.g. strona or page." + page_title_suffix: "Title suffix on page 2 and later" + page_title_suffix_comment: "Added after the page title, before the rest of the title template. Placeholders: %page%, %pages%." + page_title_suffix_default: "– page %page% of %pages%" + page_description_suffix: "Description suffix on page 2 and later" + page_description_suffix_comment: "Added at the end of the meta description. Placeholders: %page%, %pages%." + page_description_suffix_default: "Page %page% of %pages%." + page_segment_invalid: "Use lowercase letters, digits and hyphens only, e.g. strona." rules: new: New URL rule list_title: URL rules diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index e2223e4..5b15f86 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -218,6 +218,20 @@ settings: hours_order: "Godzina zamknięcia musi być późniejsza niż otwarcia." country_invalid: "Użyj dwuliterowego kodu kraju, np. PL." coordinate_invalid: "Wpisz stopnie dziesiętne: szerokość od -90 do 90, długość od -180 do 180." + tab_pagination: "Stronicowanie" + page_form: "Forma adresu strony" + page_form_query: "Parametr: /blog?page=2" + page_form_path: "Segment ścieżki: /blog/strona/2" + page_form_comment: "Zmiana przekierowuje (301) każdy adres w drugiej formie na tę. ?page=1 zawsze przekierowuje na adres bez numeru." + page_segment: "Segment ścieżki" + page_segment_comment: "Używany w formie ścieżki, osobno dla każdego języka, np. strona lub page." + page_title_suffix: "Dopisek do tytułu od strony 2" + page_title_suffix_comment: "Dodawany po tytule strony, przed resztą szablonu tytułu. Znaczniki: %page%, %pages%." + page_title_suffix_default: "– strona %page% z %pages%" + page_description_suffix: "Dopisek do opisu od strony 2" + page_description_suffix_comment: "Dodawany na końcu opisu meta. Znaczniki: %page%, %pages%." + page_description_suffix_default: "Strona %page% z %pages%." + page_segment_invalid: "Użyj tylko małych liter, cyfr i łączników, np. strona." rules: new: Nowa reguła adresu list_title: Reguły adresów diff --git a/models/settings.go b/models/settings.go index d358edf..c446c8d 100644 --- a/models/settings.go +++ b/models/settings.go @@ -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...) } diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 80e708a..e50eb14 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -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 diff --git a/settings_test.go b/settings_test.go index 417365b..e22cd8e 100644 --- a/settings_test.go +++ b/settings_test.go @@ -31,7 +31,7 @@ func TestSettingsTypeTabs(t *testing.T) { env := bootEnv(t) token := env.admin(t, true) fields := env.formSchema(t, token, "golem15/seo/settings") - if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Links | Publisher | Local business | Verification"; got != want { + if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Pagination | Links | Publisher | Local business | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } findField(t, fields, "type_acme_page_inject") @@ -81,7 +81,7 @@ func TestSettingsTypeTabs(t *testing.T) { t.Run("no other content types", func(t *testing.T) { env := bootPlugins(t, []string{"golem15.seo"}) token := env.admin(t, true) - if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Links | Publisher | Local business | Verification"; got != want { + if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Pagination | Links | Publisher | Local business | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } }) diff --git a/structured_data_test.go b/structured_data_test.go index 26614ba..ccec4f1 100644 --- a/structured_data_test.go +++ b/structured_data_test.go @@ -477,3 +477,124 @@ func TestPublisherSettings(t *testing.T) { t.Fatalf("home crumb trail = %s", got) } } + +// expectMoved asserts a 301 answer to location (one hop). +func expectMoved(t *testing.T, env *testEnv, target, location string) { + t.Helper() + res := env.resolve(t, target) + if res["action"] != "redirect" || res["status"] != float64(301) || res["location"] != siteURL+location { + t.Fatalf("%s = %v %v %v, want 301 %s", target, res["action"], res["status"], res["location"], location) + } + if next := env.resolve(t, location); next["action"] != "ok" { + t.Fatalf("%s → %s answers %v, want ok (one hop)", target, location, next["action"]) + } +} + +// expectInvalidPage asserts a not_found answer with pagination.invalid. +func expectInvalidPage(t *testing.T, env *testEnv, target string) { + t.Helper() + res := env.resolve(t, target) + p, _ := res["pagination"].(map[string]any) + if res["action"] != "not_found" || p == nil || p["invalid"] != true { + t.Fatalf("%s = %v pagination %v, want not_found with invalid", target, res["action"], res["pagination"]) + } +} + +// TestPagination covers the page address forms (D-40 to D-42, D-21, +// P33-24): 301s to the configured form in one hop, invalid values, page 2 +// and later with index,follow, a self canonical and the suffixes, and the +// site answer. +func TestPagination(t *testing.T) { + env := bootBlog(t) + token := env.admin(t, true) + const list = "/blog/category/recipes" + + t.Run("query form", func(t *testing.T) { + expectMoved(t, env, list+"?page=1", list) + res := env.resolve(t, list+"?page=3") + seo := res["seo"].(map[string]any) + if res["action"] != "ok" || res["url"] != siteURL+list+"?page=3" || seo["canonical"] != siteURL+list+"?page=3" || seo["robots"] != "index, follow" { + t.Fatalf("page 3 = %v", res) + } + if seo["title"] != "Recipes – page 3 of %pages% | Acme Blog" || seo["description"] != "Page 3 of %pages%." { + t.Fatalf("page 3 title %q description %q", seo["title"], seo["description"]) + } + if got := asJSON(t, res["pagination"]); got != `{"form":"query","invalid":false,"page":3,"param":"page","segment":""}` { + t.Fatalf("pagination = %s", got) + } + pl := env.resolve(t, "/pl/blog/kategoria/przepisy?page=3") + if title := pl["seo"].(map[string]any)["title"]; title != "Przepisy – strona 3 z %pages% | Acme Blog" { + t.Fatalf("pl page 3 title = %v", title) + } + for _, bad := range []string{"?page=abc", "?page=0", "?page=-1"} { + expectInvalidPage(t, env, list+bad) + } + expectMoved(t, env, list+"/page/3", list+"?page=3") + expectMoved(t, env, "/pl/blog/kategoria/przepisy/strona/3", "/pl/blog/kategoria/przepisy?page=3") + if res := env.resolve(t, list); res["pagination"] != nil { + t.Fatalf("unnumbered page carries pagination: %v", res["pagination"]) + } + }) + + t.Run("one hop with normalisation", func(t *testing.T) { + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"url_lowercase": true}) + expectMoved(t, env, "/Blog/Category/Recipes?page=1", list) + expectMoved(t, env, "/Blog/Category/Recipes/page/4", list+"?page=4") + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"url_lowercase": false}) + }) + + t.Run("path form", func(t *testing.T) { + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"page_form": "path"}) + expectMoved(t, env, list+"?page=3", list+"/page/3") + expectMoved(t, env, list+"/page/1", list) + expectInvalidPage(t, env, list+"/page/x") + expectInvalidPage(t, env, list+"?page=abc") + res := env.resolve(t, list+"/page/3") + if res["action"] != "ok" || res["url"] != siteURL+list+"/page/3" || res["seo"].(map[string]any)["canonical"] != siteURL+list+"/page/3" { + t.Fatalf("path page 3 = %v", res) + } + if got := asJSON(t, res["pagination"]); got != `{"form":"path","invalid":false,"page":3,"param":"page","segment":"page"}` { + t.Fatalf("pagination = %s", got) + } + if pl := env.resolve(t, "/pl/blog/kategoria/przepisy/strona/2"); pl["action"] != "ok" || pl["pagination"].(map[string]any)["segment"] != "strona" { + t.Fatalf("pl path page = %v", pl) + } + expectMoved(t, env, "/pl/blog/kategoria/przepisy/page/2", "/pl/blog/kategoria/przepisy/strona/2") + // A record whose own address ends like a page stays a record. + if res := env.resolve(t, "/blog/first-post"); res["action"] != "ok" { + t.Fatalf("post = %v", res["action"]) + } + }) + + t.Run("TestSiteEndpoint", func(t *testing.T) { + site := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil).Body.String() + for _, want := range []string{`"pagination":{"form":"path","param":"page","segments":{"en":"page","pl":"strona"}}`, `"home_crumb":{"name":"Home","url":"https://www.example.test/","names":{"en":"Home","pl":"Strona główna"}}`} { + if !jsonContains(site, want) { + t.Fatalf("site answer lacks %s: %s", want, site) + } + } + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"breadcrumb_home": "Start", "page_segment": "p"}) + site = env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil).Body.String() + if !jsonContains(site, `"home_crumb":{"name":"Start"`) || !jsonContains(site, `"segments":{"en":"p"`) { + t.Fatalf("site answer after the change: %s", site) + } + }) + + t.Run("refusals", func(t *testing.T) { + refusal(t, env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"page_segment": "Strona!"}), "page_segment", "Use lowercase letters, digits and hyphens only") + refusal(t, env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"page_title_suffix": "– %foo%"}), "page_title_suffix", "Unknown placeholder “%foo%”") + refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"page_form": "hash"}), "page_form", "") + }) +} + +// TestPagedAlternates: the hreflang alternates of page 2 and later point +// at the same page of each translation. +func TestPagedAlternates(t *testing.T) { + env := bootBlog(t) + resetBlog() + res := env.resolve(t, "/blog/first-post?page=2") + want := `[{"hreflang":"en","url":"https://www.example.test/blog/first-post?page=2"},{"hreflang":"pl","url":"https://www.example.test/pl/blog/pierwszy-wpis?page=2"},{"hreflang":"x-default","url":"https://www.example.test/blog/first-post?page=2"}]` + if got := asJSON(t, res["alternates"]); got != want { + t.Fatalf("paged alternates = %s", got) + } +}