Files
sm-seo-plugin/controllers/api/settings.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

147 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"encoding/json"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// 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)"`
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
// type, its label and settings.
func settingsJSON(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Settings) (map[string]any, error) {
stored, err := classes.LoadTypeSettings(db)
if err != nil {
return nil, err
}
types := map[string]any{}
if reg := svc.Registry(); reg != nil {
for _, typ := range reg.ContentTypes() {
ts, ok := stored[typ.Key]
if !ok {
ts = models.DefaultTypeSetting(typ.Key)
}
v := classes.TypeSettingValues(ts)
v["label"] = svc.PhraseCtx(ctx, typ.Label, nil)
types[typ.Key] = v
}
}
return map[string]any{"settings": classes.SettingsValues(row), "types": types}, nil
}
// ShowSettings serves GET /_seo/api/v1/settings.
func ShowSettings(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
row, err := classes.LoadSettings(db)
if err != nil {
return 0, nil, err
}
body, err := settingsJSON(ctx, svc, db, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}
// UpdateSettings serves PUT /_seo/api/v1/settings: the fields sent change,
// with the checks of the settings screen; 422 validation_failed names the
// refused fields.
func UpdateSettings(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
raw := map[string]json.RawMessage{}
if err := decode(r, maxBody, &raw); err != nil {
return 0, nil, err
}
in := classes.SettingsWrite{Values: map[string]any{}}
for key, value := range raw {
if key == "types" {
if err := json.Unmarshal(value, &in.Types); err != nil {
return 0, nil, Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
continue
}
var v any
if err := json.Unmarshal(value, &v); err != nil {
return 0, nil, Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
in.Values[key] = v
}
var row models.Settings
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
row, err = svc.SaveSettings(ctx, tx, in)
return err
})
if err != nil {
return 0, nil, err
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
body, err := settingsJSON(ctx, svc, db, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}