Files
sm-seo-plugin/controllers/api/seo.go
Jakub Zych cadf6ad805 feat: SEO by URL over the management API and MCP (tracer)
- PUT /_seo/api/v1/seo writes to the record behind a URL in its locale,
  else to an exact URL rule (force_rule writes a rule); the answer says
  where (D-11)
- management routes resolve the backend administrator from a token or
  the MCP dispatch and require golem15.seo.manage (D-10, D-15)
- MCP tool set_url_seo; history records via mcp
- classes.WithTx, FindOwner, SetURLSEO, SaveRule and DeleteRule
2026-10-27 12:06:31 +01:00

72 lines
3.0 KiB
Go

package api
import (
"context"
"net/http"
"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"`
}
// 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,
} {
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; 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
})
}