Files
sm-seo-plugin/classes/owner.go
Jakub Zych 17da132b00 feat: graph validation and custom JSON-LD
- seo_schema (seoschema, E8) with the C17 server rules: invalid JSON with
  line and column, non-objects, 16 KB, depth 8, a literal <script and a
  type outside the kind's list refused; the value re-serialised
- a custom node with a generated @id merges into it (schema.merged save
  notice), other nodes are appended to the graph
- seo_primary_parent (seoparent) for article types; both values stored
  in the entry data on every locale row
- the seo_graph widget action (E9 data), P1 primary_gone and the P2
  schema type and breadcrumb trail rows
- GET /_seo/api/v1/graph and MCP seo_get_graph; seo_set_url_seo and
  apply_batch accept primary_parent and schema

The graph tool lands with the validation: the operations parity test
binds the seo_graph widget action to its tool.
2026-11-04 14:33:10 +01:00

233 lines
8.0 KiB
Go

package classes
import (
"context"
"net/url"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Where a change addressed by URL is written (D-11).
const (
WrittenToRecord = "record"
WrittenToRule = "rule"
)
// Owner is the owner of a URL's SEO (D-11, P-25): the record behind the
// URL in the matched locale, or an exact URL rule. URL is the address in
// the site's form (normalised path with its query); RuleID is the rule's
// id once it exists. Published is true for a published record.
type Owner struct {
WrittenTo string `json:"written_to"`
URL string `json:"url"`
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
Locale string `json:"locale,omitempty"`
RuleID uint `json:"rule_id,omitempty"`
Published bool `json:"-"`
}
// FindOwner resolves the owner of rawURL (a path or an absolute URL of the
// site) inside db: the URL is normalised with the site's address rules,
// then the signpost patterns (MatchPath) name the record behind it. An
// exact match without a query is the record's SEO in the matched locale;
// anything else, or forceRule, is an exact URL rule on the normalised
// address. ErrBadURL refuses an input that is not a path or http(s) URL.
func (s *Service) FindOwner(ctx context.Context, db *gorm.DB, rawURL string, forceRule bool) (Owner, pact.MenuItemType, error) {
path, _, err := parseTarget(rawURL)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
settings, err := LoadSettings(db)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
norm, _ := Normalize(&url.URL{Path: path, RawQuery: rawQueryOf(rawURL)}, NormalizeOptionsOf(settings))
address := joinQuery(norm.Path, norm.RawQuery)
if !forceRule && norm.RawQuery == "" {
if reg := s.Registry(); reg != nil {
unprefixed := s.UnprefixedLocale()
key, match, err := reg.MatchPath(ctx, trimSlash(norm.Path), unprefixed)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
if match != nil && match.Exact {
typ, ok := reg.ContentType(key)
if ok && typ.Content != nil {
owner := Owner{WrittenTo: WrittenToRecord, URL: address, Type: key, ID: match.ID, Locale: match.Locale}
if rec, err := reg.Describe(ctx, key, match.ID, unprefixed); err == nil && rec != nil {
owner.Published = rec.Published
}
return owner, typ, nil
}
}
}
}
owner := Owner{WrittenTo: WrittenToRule, URL: address}
rule, found, err := ExactRule(db, address)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
if found {
owner.RuleID = rule.ID
}
return owner, pact.MenuItemType{}, nil
}
// ExactRule returns the exact URL rule of address; found is false when
// there is none.
func ExactRule(db *gorm.DB, address string) (models.Rule, bool, error) {
var rows []models.Rule
if err := db.Where("url = ? AND match = ?", address, models.MatchExact).Limit(1).Find(&rows).Error; err != nil {
return models.Rule{}, false, err
}
if len(rows) == 0 {
return models.Rule{}, false, nil
}
return rows[0], true, nil
}
// URLSEOWrite is one change of a URL's SEO (D-11): the address, the
// locale of a record's entry (empty takes the locale the URL matched),
// the values to write (column -> value; an absent column is left as it
// is, an empty value inherits), and ForceRule to write a URL rule even
// when a record is behind the URL.
type URLSEOWrite struct {
URL string
Locale string
Values map[string]string
ForceRule bool
}
// URLSEOResult is where a URL change was written and the stored values
// of the owner after the write.
type URLSEOResult struct {
Owner
Values map[string]string `json:"values"`
}
// SetURLSEO writes in.Values to the owner of in.URL inside tx (D-11): the
// record's SEO entry when a record is behind the URL, else an exact URL
// rule, created when missing. Robots values are brought to the C5 grammar,
// a canonical must be a path or an http(s) URL, and the focus keyphrase
// belongs to records only; problems are refused with a *ProblemsError.
// History records the write with the via of ctx.
func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (URLSEOResult, error) {
owner, typ, err := s.FindOwner(ctx, tx, in.URL, in.ForceRule)
if err != nil {
return URLSEOResult{}, err
}
values := map[string]string{}
problems := map[string][]models.Problem{}
known := map[string]bool{}
for _, column := range EntryColumns {
known[column] = true
}
for _, column := range DataColumns {
known[column] = true
}
for column, raw := range in.Values {
v := strings.TrimSpace(raw)
switch {
case !known[column]:
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
continue
case column == "keyphrase" && owner.WrittenTo == WrittenToRule && v != "":
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.keyphrase_record_only"})
continue
case column == "robots" && v != "":
normal, err := NormalizeRobots(v)
if err != nil {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
continue
}
v = normal
case column == "canonical" && v != "" && !ValidURLRef(v):
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
continue
case column == "og_image" && !ValidImageURL(v):
problems[column] = append(problems[column], models.Problem{Key: errImageURL})
continue
case (column == DataPrimaryParent || column == DataSchema) && owner.WrittenTo != WrittenToRecord:
if v != "" {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.record_only"})
}
continue
case column == DataPrimaryParent && v != "":
found := false
for _, opt := range s.ParentOptions(ctx, typ.Key, owner.ID, "") {
found = found || opt.Value == v
}
if !found {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
continue
}
case column == DataSchema:
stored, _, err := schema.Normalize(typ.Content.Kind, pageVariant(tx, typ.Key, owner.ID), v)
if err != nil {
problems[column] = append(problems[column], SchemaProblem(typ.Content.Kind, err))
continue
}
v = stored
}
values[column] = v
}
if len(problems) > 0 {
return URLSEOResult{}, &ProblemsError{Problems: problems}
}
if owner.WrittenTo == WrittenToRecord {
locale := owner.Locale
if l := strings.TrimSpace(in.Locale); l != "" && l != locale {
// Another locale must be one the record describes (WR-18): an
// entry in any other would never be read by the resolver or the
// SEO tab.
if _, known := s.recordLocales(ctx, typ.Key, owner.ID)[l]; !known {
return URLSEOResult{}, &ProblemsError{Problems: map[string][]models.Problem{
"locale": {{Key: "golem15.seo::lang.api.locale_unknown", Params: map[string]string{"locale": l}}},
}}
}
locale = l
}
owner.Locale = locale
write := RecordValues{}
for column, v := range values {
write[column] = map[string]string{locale: v}
}
_, after, err := s.SaveRecord(ctx, tx, RecordWrite{Type: typ, ID: owner.ID, Values: write})
if err != nil {
return URLSEOResult{}, err
}
out := map[string]string{}
e := after[locale]
for _, column := range EntryColumns {
out[column] = EntryValue(e, column)
}
for _, column := range DataColumns {
out[column] = EntryValue(e, column)
}
return URLSEOResult{Owner: owner, Values: out}, nil
}
rule := models.Rule{URL: owner.URL, Match: models.MatchExact}
if owner.RuleID != 0 {
if rule, err = LoadRule(tx, owner.RuleID); err != nil {
return URLSEOResult{}, err
}
}
for column, v := range values {
SetRuleValue(&rule, column, v)
}
if err := s.SaveRule(ctx, tx, &rule); err != nil {
return URLSEOResult{}, err
}
owner.RuleID = rule.ID
out := map[string]string{}
for _, p := range models.RuleProperties {
out[p.Column] = rule.Value(p.Column)
}
return URLSEOResult{Owner: owner, Values: out}, nil
}