feat: JSON-LD graph, breadcrumbs and hreflang in the resolve answer (tracer)

- classes/schema builds one @graph per page (WebPage, article, profile,
  service, BreadcrumbList, VideoObject, WebSite, publisher) linked by @id
- breadcrumb trail through the primary parent chain, hreflang alternates
  with x-default, html_lang and og:locale in the resolve answer
- migration 202610280004 adds the publisher, local business, home crumb
  and pagination settings columns
This commit is contained in:
Jakub Zych
2026-11-04 14:06:57 +01:00
parent df93c86001
commit ea2ce24954
12 changed files with 1676 additions and 2 deletions

162
classes/breadcrumbs.go Normal file
View File

@@ -0,0 +1,162 @@
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})
}