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:
162
classes/breadcrumbs.go
Normal file
162
classes/breadcrumbs.go
Normal 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})
|
||||
}
|
||||
Reference in New Issue
Block a user