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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user