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:
Jakub Zych
2026-11-04 14:22:21 +01:00
parent fde5024b5a
commit 032d3dae45
12 changed files with 763 additions and 144 deletions

257
classes/pagination.go Normal file
View 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
}

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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 {

View File

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