- /_seo/api/v1 routes for the effective SEO of a URL with sources and owner, URL rules, redirects (loops refused with redirect_loop), the 404 log actions, robots.txt and its tester, settings and history - the CMS-data issue report: missing and duplicate titles and descriptions, rules over records, redirect chains, canonicals to noindex or redirected addresses, noindex addresses robots.txt blocks - 24 MCP tools on those routes (backend guard, golem15.seo.manage) - AdminOperations maps every admin operation to its tool; TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift - robots.txt writes go through classes.SaveRobots from the admin too
137 lines
5.1 KiB
Go
137 lines
5.1 KiB
Go
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"`
|
|
}
|
|
|
|
// 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
|
|
})
|
|
}
|
|
|
|
// 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
|
|
})
|
|
}
|