Files
sm-seo-plugin/classes/breadcrumbs.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

199 lines
6.3 KiB
Go

package classes
import (
"context"
"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"
)
// The entry data keys of the structured data fields (P33-23): one value per
// record, written on every locale row as the social image is.
const (
DataPrimaryParent = "primary_parent"
DataSchema = "schema"
)
// MaxCrumbDepth caps the parent chain of a breadcrumb trail (T-33-28).
const MaxCrumbDepth = 6
// ParentValue is the stored form of a parent reference: <type>:<id>.
func ParentValue(ref pact.ContentRef) string { return ref.Type + ":" + ref.ID }
// entryData reads a string value of an entry's data.
func entryData(e models.Entry, key string) string {
if e.Data == nil {
return ""
}
s, _ := e.Data[key].(string)
return strings.TrimSpace(s)
}
// PrimaryParent is a record's primary parent (D-86): the stored choice
// when the record still has that parent, else its first parent. gone is
// true when a stored choice is no longer one of the parents; ok is false
// when the record has no parent.
func PrimaryParent(rec *pact.ContentRecord, stored string) (ref pact.ContentRef, ok, gone bool) {
if rec == nil || len(rec.Parents) == 0 {
return pact.ContentRef{}, false, stored != ""
}
if stored != "" {
for _, p := range rec.Parents {
if ParentValue(p) == stored {
return p, true, false
}
}
gone = true
}
return rec.Parents[0], true, gone
}
// localeOf returns the record's description in locale exactly; ok is
// false when it has none with an address.
func localeOf(rec *pact.ContentRecord, locale string) (pact.ContentLocale, bool) {
if rec == nil {
return pact.ContentLocale{}, false
}
for _, l := range rec.Locales {
if l.Locale == locale && strings.TrimSpace(l.URL) != "" {
return l, true
}
}
return pact.ContentLocale{}, false
}
// HomeCrumbName is the home crumb's name in locale: the setting, else
// the default copy.
func (s *Service) HomeCrumbName(ctx context.Context, db *gorm.DB, settings models.Settings, locale string) string {
name := strings.TrimSpace(s.translatedSetting(ctx, db, &settings, "breadcrumb_home", settings.BreadcrumbHome, locale))
if name == "" {
name = s.PhraseIn(locale, "golem15.seo::lang.settings.breadcrumb_home_default", nil)
}
return name
}
// homePath is the home page of a locale's address: /<locale> when the
// page's path carries that prefix, else /.
func homePath(locale, path string) string {
if locale != "" {
prefix := "/" + strings.ToLower(locale)
if lower := strings.ToLower(path); lower == prefix || strings.HasPrefix(lower, prefix+"/") {
return prefix
}
}
return "/"
}
// parentChain lists a record's parents for its trail, the nearest last:
// the primary parent (the stored choice, else the first parent), then that
// parent's primary parent, at most MaxCrumbDepth levels. An unpublished
// parent or one without an address in locale is left out of the trail but
// its own parent is still followed; a cycle ends the chain.
func (s *Service) parentChain(ctx context.Context, db *gorm.DB, rec *pact.ContentRecord, stored, locale string) []parentCrumb {
reg := s.Registry()
if reg == nil || rec == nil {
return nil
}
var out []parentCrumb
seen := map[string]bool{}
current, choice := rec, stored
for depth := 0; depth < MaxCrumbDepth; depth++ {
ref, ok, _ := PrimaryParent(current, choice)
if !ok || seen[ParentValue(ref)] {
break
}
seen[ParentValue(ref)] = true
parent, err := reg.Describe(ctx, ref.Type, ref.ID, s.UnprefixedLocale())
if err != nil || parent == nil {
break
}
if l, has := localeOf(parent, locale); has && parent.Published {
out = append(out, parentCrumb{ref: ref, locale: l})
}
choice = ""
if typ, ok := reg.ContentType(ref.Type); ok && typ.Content != nil {
if e, found, err := LoadEntry(db, typ.Content.Model, ref.ID, locale); err == nil && found {
choice = entryData(e, DataPrimaryParent)
}
}
current = parent
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// parentCrumb is one parent of a trail.
type parentCrumb struct {
ref pact.ContentRef
locale pact.ContentLocale
}
// Trail is a page's breadcrumb trail (D-86): the home crumb, the record's
// parent chain and the page itself, last. The home page's trail is the
// home crumb only. The breadcrumbs of the resolve answer and the
// BreadcrumbList node are both this list.
func (s *Service) Trail(ctx context.Context, db *gorm.DB, settings models.Settings, rec *pact.ContentRecord, stored, locale, path, name string) []schema.Crumb {
site := s.SiteURL()
home := homePath(locale, path)
homeURL := site + home
if home == "/" {
homeURL = site + "/"
}
out := []schema.Crumb{{Name: s.HomeCrumbName(ctx, db, settings, locale), URL: homeURL, Path: home}}
if path == "/" || path == home {
return out
}
for _, p := range s.parentChain(ctx, db, rec, stored, locale) {
title := strings.TrimSpace(p.locale.Title)
if title == "" {
continue
}
out = append(out, schema.Crumb{Name: title, URL: AbsoluteURL(site, p.locale.URL), Path: p.locale.URL})
}
if strings.TrimSpace(name) == "" {
name = path
}
return append(out, schema.Crumb{Name: name, URL: site + path, Path: path})
}
// ParentOption is one choice of the primary category field (P33-23).
type ParentOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ParentOptions lists a record's parents as primary category choices, in
// descriptor order, labelled with their title in locale (the default
// locale when "").
func (s *Service) ParentOptions(ctx context.Context, key, id, locale string) []ParentOption {
out := []ParentOption{}
reg := s.Registry()
if reg == nil || key == "" || id == "" {
return out
}
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
if err != nil || rec == nil {
return out
}
if locale == "" {
if db, err := s.DB(ctx); err == nil {
locale = s.DefaultLocale(ctx, db)
}
}
for _, ref := range rec.Parents {
label := ParentValue(ref)
if parent, err := reg.Describe(ctx, ref.Type, ref.ID, s.UnprefixedLocale()); err == nil && parent != nil {
if l := contentLocale(parent, locale); strings.TrimSpace(l.Title) != "" {
label = l.Title
}
}
out = append(out, ParentOption{Value: ParentValue(ref), Label: label})
}
return out
}