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:
257
classes/pagination.go
Normal file
257
classes/pagination.go
Normal file
@@ -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 /<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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<segment>/2); the other form, ?page=1 and /<segment>/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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user