- 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.
199 lines
6.3 KiB
Go
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
|
|
}
|