package api import ( "context" "net/http" "strings" "git.golem15.com/golem15/sm-seo-plugin/classes" "gorm.io/gorm" ) // SEOValues are the SEO values of a URL in a request: a value that is // absent is left as it is, an empty one inherits. type SEOValues struct { Title *string `json:"title,omitempty" jsonschema:"the SEO title"` Description *string `json:"description,omitempty" jsonschema:"the meta description"` Keyphrase *string `json:"keyphrase,omitempty" jsonschema:"the focus keyphrase (records only)"` Canonical *string `json:"canonical,omitempty" jsonschema:"the canonical URL: a path or an absolute http(s) URL"` Robots *string `json:"robots,omitempty" jsonschema:"robots directives, for example noindex,follow"` OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"` OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"` OGImage *string `json:"og_image,omitempty" jsonschema:"the social image: a path or an absolute URL"` PrimaryParent *string `json:"primary_parent,omitempty" jsonschema:"records only: the primary category of the breadcrumb trail and the article section, one of the record's parents as : (seo_get_url_seo and the graph tool name them); empty for the first parent. Shared by every locale"` Schema *string `json:"schema,omitempty" jsonschema:"records only: the structured data as a JSON string {\"type\": schema type or empty for the default, \"fields\": {serviceType, areaServed, price, currency} for Service, \"custom\": custom JSON-LD text (an object or a list, at most 16 KB, no script tag; a node with a generated @id is merged into it)}; empty resets it. Shared by every locale"` } // Map returns the values that are set, keyed by column. func (v SEOValues) Map() map[string]string { out := map[string]string{} for column, p := range map[string]*string{ "title": v.Title, "description": v.Description, "keyphrase": v.Keyphrase, "canonical": v.Canonical, "robots": v.Robots, "og_title": v.OGTitle, "og_description": v.OGDescription, "og_image": v.OGImage, "primary_parent": v.PrimaryParent, "schema": v.Schema, } { if p != nil { out[column] = *p } } return out } // SetSEOInput is the body of PUT /_seo/api/v1/seo. type SetSEOInput struct { URL string `json:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"` Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO, one of the record's locales (422 locale_unknown otherwise); the locale the URL matches when omitted"` Values SEOValues `json:"values" jsonschema:"the values to change"` ForceRule bool `json:"force_rule,omitempty" jsonschema:"write a URL rule even when a record is behind the URL"` } // SetSEO serves PUT /_seo/api/v1/seo (D-11, P-25): the values go to the // record behind the URL in its locale, else to an exact URL rule (created // when missing), or to a rule when force_rule is set. It answers 200 with // where the change was written ({"written_to":"record"|"rule", "type", // "id", "locale", "rule_id"}) and the stored values; 422 for a URL that is // not a path or http(s) URL or for invalid values. History records the // write with via mcp when it comes through MCP. func SetSEO(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { var in SetSEOInput if err := decode(r, maxBody, &in); err != nil { return 0, nil, err } var res classes.URLSEOResult err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var err error res, err = svc.SetURLSEO(ctx, tx, classes.URLSEOWrite{URL: in.URL, Locale: in.Locale, Values: in.Values.Map(), ForceRule: in.ForceRule}) return err }) if err != nil { return 0, nil, err } return http.StatusOK, res, nil }) } // GetSEOInput is the query of GET /_seo/api/v1/seo. type GetSEOInput struct { URL string `json:"url" query:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"` } // GetSEO serves GET /_seo/api/v1/seo?url= (D-13): the effective SEO of a // URL with the source of every value (rule, record, template, global, // content or default), the redirect decision (action redirect or gone with // the location), the owner a change would be written to, and the P1 // warnings (a URL rule overriding the record, a canonical that redirects // or points to a noindex page). 422 for a URL that is not a path or // http(s) URL. func GetSEO(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { target := r.URL.Query().Get("url") res, err := svc.Resolver().Resolve(ctx, target) if err != nil { return 0, nil, err } db, err := read(ctx, svc) if err != nil { return 0, nil, err } owner, _, err := svc.FindOwner(ctx, db, target, false) if err != nil { return 0, nil, err } var status classes.StatusView switch { case owner.WrittenTo == classes.WrittenToRecord: if status, err = svc.RecordStatus(ctx, owner.Type, owner.ID); err != nil { return 0, nil, err } case owner.RuleID != 0: rule, err := classes.LoadRule(db, owner.RuleID) if err != nil { return 0, nil, err } status = svc.RuleStatus(ctx, rule) } warnings := []string{} for _, c := range status.Callouts { text := strings.TrimSpace(c.Title + " " + c.Text) warnings = append(warnings, text) } sources := res.Sources if sources == nil { sources = map[string]string{} } return http.StatusOK, map[string]any{ "url": res.URL, "action": res.Action, "status": res.Status, "location": res.Location, "locale": res.Locale, "staging": res.Staging, "seo": res.SEO, "sources": sources, "owner": owner, "warnings": warnings, }, nil }) }