Files
sm-seo-plugin/classes/site.go
Jakub Zych 032d3dae45 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
2026-11-04 14:22:21 +01:00

167 lines
5.3 KiB
Go

package classes
import (
"context"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
)
// SiteAnswer is the answer of GET /_seo/api/v1/site (P33-17): the facts
// about the whole site the frontend layer needs, all public. SiteURL,
// DefaultLocale, Locales and UnprefixedLocale are the resolver's; Links
// the settings' new-tab switch; HTMLSitemap the sitemap plugin's HTML
// 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; 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"`
Locales []string `json:"locales"`
UnprefixedLocale string `json:"unprefixed_locale"`
Links SiteLinks `json:"links"`
HTMLSitemap SiteHTMLSitemap `json:"html_sitemap"`
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.
type SiteLinks struct {
NewTab bool `json:"new_tab"`
}
// SiteHTMLSitemap is the site answer's HTML sitemap block: whether the
// sitemap plugin serves it, its path per locale and its page size.
type SiteHTMLSitemap struct {
Enabled bool `json:"enabled"`
Paths map[string]string `json:"paths"`
PageSize int `json:"page_size"`
}
// SiteRoute is one content type's URL pattern.
type SiteRoute struct {
Type string `json:"type"`
Pattern string `json:"pattern"`
}
// listing returns the sitemap plugin's published contract.Listing, nil
// when none is published.
func (s *Service) listing() contract.Listing {
if s == nil || s.app == nil {
return nil
}
l, ok := s.app.Lookup[contract.Listing]()
if !ok {
return nil
}
return l
}
// Site builds the site answer. It names only public facts: no token, no
// unpublished data (T-33-19).
func (s *Service) Site(ctx context.Context) (SiteAnswer, error) {
out := SiteAnswer{
SiteURL: s.SiteURL(),
UnprefixedLocale: s.UnprefixedLocale(),
HTMLSitemap: SiteHTMLSitemap{Paths: map[string]string{}},
Routes: []SiteRoute{},
RootFiles: []string{},
}
db, err := s.DB(ctx)
if err != nil {
return out, err
}
settings, err := LoadSettings(db)
if err != nil {
return out, err
}
robots, err := LoadRobots(db)
if err != nil {
return out, err
}
out.DefaultLocale = s.DefaultLocale(ctx, db)
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 {
continue
}
out.Routes = append(out.Routes, SiteRoute{Type: typ.Key, Pattern: reg.Pattern(typ.Key, "")})
}
}
seen := map[string]bool{}
addFile := func(name string) {
name = strings.TrimSpace(name)
if name != "" && verificationName.MatchString(name) && !seen[name] {
seen[name] = true
out.RootFiles = append(out.RootFiles, name)
}
}
for _, f := range settings.Files() {
addFile(f.Filename)
}
if l := s.listing(); l != nil {
st, err := l.Settings(ctx)
if err != nil {
return out, err
}
out.HTMLSitemap.Enabled = st.HTMLEnabled
for locale, path := range st.HTMLPaths {
if strings.TrimSpace(path) != "" {
out.HTMLSitemap.Paths[locale] = path
}
}
out.HTMLSitemap.PageSize = st.HTMLPageSize
addFile(st.KeyFile)
}
return out, nil
}
// SiteName is the site's name in locale: the settings' site name, else
// the application name.
func (s *Service) SiteName(ctx context.Context, settings models.Settings, locale string) string {
db, err := s.DB(ctx)
name := settings.SiteName
if err == nil {
name = s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale)
}
if strings.TrimSpace(name) == "" {
if cfg := s.Config(); cfg != nil {
name = cfg.String("app.name")
}
}
return strings.TrimSpace(name)
}