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:
222
classes/structured.go
Normal file
222
classes/structured.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
// PhraseIn renders a phrase key in locale; the key itself when the
|
||||
// application has no translator.
|
||||
func (s *Service) PhraseIn(locale, key string, params map[string]string) string {
|
||||
tr := s.translator()
|
||||
if tr == nil {
|
||||
return key
|
||||
}
|
||||
return tr.GetIn(locale, key, params)
|
||||
}
|
||||
|
||||
// SearchURL is the configured site search address, "" when unset.
|
||||
func (s *Service) SearchURL() string {
|
||||
if cfg := s.Config(); cfg != nil {
|
||||
return strings.TrimSpace(cfg.String(SearchURLConfigKey))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SameAsLines splits a sameAs list into its trimmed, non-empty lines.
|
||||
func SameAsLines(list string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(strings.ReplaceAll(list, "\r\n", "\n"), "\n") {
|
||||
if v := strings.TrimSpace(line); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PublisherOf is the settings' publisher in locale (D-45): its name is the
|
||||
// translated publisher name (empty: the site name, filled by the builder),
|
||||
// URLs absolute on the site, and the local business data only for an
|
||||
// organisation with the switch on.
|
||||
func (s *Service) PublisherOf(ctx context.Context, db *gorm.DB, settings models.Settings, locale string) schema.Publisher {
|
||||
site := s.SiteURL()
|
||||
p := schema.Publisher{
|
||||
Type: schema.PublisherOrganization,
|
||||
Name: strings.TrimSpace(s.translatedSetting(ctx, db, &settings, "publisher_name", settings.PublisherName, locale)),
|
||||
Email: strings.TrimSpace(settings.PublisherEmail),
|
||||
Phone: strings.TrimSpace(settings.PublisherPhone),
|
||||
}
|
||||
if settings.PublisherType == models.PublisherPerson {
|
||||
p.Type = schema.PublisherPerson
|
||||
}
|
||||
if logo := strings.TrimSpace(settings.PublisherLogo); logo != "" {
|
||||
p.Logo = AbsoluteURL(site, logo)
|
||||
}
|
||||
if u := strings.TrimSpace(settings.PublisherURL); u != "" {
|
||||
p.URL = AbsoluteURL(site, u)
|
||||
}
|
||||
p.SameAs = SameAsLines(settings.PublisherSameAs)
|
||||
if settings.LocalEnabled && p.Type == schema.PublisherOrganization {
|
||||
l := &schema.Local{
|
||||
Type: strings.TrimSpace(settings.LocalType),
|
||||
PriceRange: strings.TrimSpace(settings.LocalPriceRange),
|
||||
Street: strings.TrimSpace(settings.LocalStreet),
|
||||
PostalCode: strings.TrimSpace(settings.LocalPostalCode),
|
||||
City: strings.TrimSpace(settings.LocalCity),
|
||||
Region: strings.TrimSpace(settings.LocalRegion),
|
||||
Country: strings.TrimSpace(settings.LocalCountry),
|
||||
}
|
||||
if lat, err := strconv.ParseFloat(strings.TrimSpace(settings.LocalLatitude), 64); err == nil {
|
||||
if lng, err := strconv.ParseFloat(strings.TrimSpace(settings.LocalLongitude), 64); err == nil {
|
||||
l.Latitude, l.Longitude = &lat, &lng
|
||||
}
|
||||
}
|
||||
for _, h := range settings.Hours() {
|
||||
l.Hours = append(l.Hours, schema.Hours{Day: h.Day, Opens: h.Opens, Closes: h.Closes})
|
||||
}
|
||||
p.Local = l
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// pageVariant is the registry kind of a registry page, "" for other types.
|
||||
func pageVariant(db *gorm.DB, key, id string) string {
|
||||
if key != PageTypeKey || id == "" {
|
||||
return ""
|
||||
}
|
||||
kinds, err := pageKinds(db, []string{id})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return kinds[id]
|
||||
}
|
||||
|
||||
// structuredIn holds what resolveAt hands to the structured data.
|
||||
type structuredIn struct {
|
||||
key string
|
||||
id string
|
||||
rec *pact.ContentRecord
|
||||
entry models.Entry
|
||||
content pact.ContentLocale
|
||||
locale string
|
||||
path string
|
||||
url string
|
||||
home bool
|
||||
siteName string
|
||||
seo *SEO
|
||||
}
|
||||
|
||||
// graphInput composes the builder's input of a page.
|
||||
func (s *Service) graphInput(ctx context.Context, db *gorm.DB, settings models.Settings, in structuredIn, crumbs []schema.Crumb) schema.Input {
|
||||
site := s.SiteURL()
|
||||
name := strings.TrimSpace(in.content.Title)
|
||||
if name == "" && in.home {
|
||||
name = in.siteName
|
||||
}
|
||||
if name == "" && in.seo != nil {
|
||||
name = in.seo.Title
|
||||
}
|
||||
gi := schema.Input{
|
||||
Site: site,
|
||||
URL: in.url,
|
||||
Lang: LangTag(in.locale),
|
||||
Home: in.home,
|
||||
Name: name,
|
||||
Headline: strings.TrimSpace(in.content.Title),
|
||||
Crumbs: crumbs,
|
||||
SiteName: in.siteName,
|
||||
SearchURL: s.SearchURL(),
|
||||
Publisher: s.PublisherOf(ctx, db, settings, in.locale),
|
||||
Words: in.content.Words,
|
||||
}
|
||||
if in.seo != nil {
|
||||
gi.Description = in.seo.Description
|
||||
}
|
||||
if in.key != "" {
|
||||
if typ, ok := s.Registry().ContentType(in.key); ok && typ.Content != nil {
|
||||
gi.Kind = typ.Content.Kind
|
||||
}
|
||||
gi.Variant = pageVariant(db, in.key, in.id)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
addImage := func(u string) {
|
||||
if u = strings.TrimSpace(u); u == "" {
|
||||
return
|
||||
}
|
||||
u = AbsoluteURL(site, u)
|
||||
if !seen[u] {
|
||||
seen[u] = true
|
||||
gi.Images = append(gi.Images, u)
|
||||
}
|
||||
}
|
||||
for _, img := range in.content.Images {
|
||||
if !img.Decorative {
|
||||
addImage(img.URL)
|
||||
}
|
||||
}
|
||||
addImage(in.content.Image)
|
||||
if len(gi.Images) == 0 && in.seo != nil {
|
||||
addImage(in.seo.OG.Image)
|
||||
}
|
||||
if rec := in.rec; rec != nil {
|
||||
gi.PublishedAt, gi.ModifiedAt = rec.PublishedAt, rec.LastMod
|
||||
for _, a := range rec.Authors {
|
||||
p := schema.Person{Name: a.Name}
|
||||
if a.Ref.Type != "" {
|
||||
if l, ok := s.describeLocale(ctx, a.Ref, in.locale); ok {
|
||||
p.URL = AbsoluteURL(site, l.URL)
|
||||
}
|
||||
}
|
||||
gi.Authors = append(gi.Authors, p)
|
||||
}
|
||||
for _, v := range in.content.Videos {
|
||||
gi.Videos = append(gi.Videos, schema.Video{Provider: v.Provider, ID: v.ID})
|
||||
}
|
||||
}
|
||||
if len(crumbs) >= 3 {
|
||||
gi.Section = crumbs[len(crumbs)-2].Name
|
||||
}
|
||||
return gi
|
||||
}
|
||||
|
||||
// describeLocale describes a referenced record in locale.
|
||||
func (s *Service) describeLocale(ctx context.Context, ref pact.ContentRef, locale string) (pact.ContentLocale, bool) {
|
||||
reg := s.Registry()
|
||||
if reg == nil {
|
||||
return pact.ContentLocale{}, false
|
||||
}
|
||||
rec, err := reg.Describe(ctx, ref.Type, ref.ID, s.UnprefixedLocale())
|
||||
if err != nil || rec == nil || !rec.Published {
|
||||
return pact.ContentLocale{}, false
|
||||
}
|
||||
return localeOf(rec, locale)
|
||||
}
|
||||
|
||||
// structuredData fills the structured keys of an ok answer: html_lang,
|
||||
// og:locale and its alternates, the hreflang alternates, the breadcrumb
|
||||
// trail and the JSON-LD graph (D-43, D-55 to D-57, D-86).
|
||||
func (s *Service) structuredData(ctx context.Context, db *gorm.DB, settings models.Settings, res *Result, in structuredIn) error {
|
||||
res.HTMLLang = LangTag(in.locale)
|
||||
if res.SEO != nil {
|
||||
res.SEO.OG.Locale = s.OGLocale(in.locale)
|
||||
res.SEO.OG.LocaleAlternates = s.OGLocaleAlternates(in.rec, in.locale)
|
||||
}
|
||||
res.Alternates = Alternates(s.SiteURL(), in.rec, s.DefaultLocale(ctx, db))
|
||||
name := strings.TrimSpace(in.content.Title)
|
||||
if name == "" && res.SEO != nil {
|
||||
name = res.SEO.Title
|
||||
}
|
||||
res.Breadcrumbs = s.Trail(ctx, db, settings, in.rec, entryData(in.entry, DataPrimaryParent), in.locale, in.path, name)
|
||||
graph, err := schema.Build(ctx, s.graphInput(ctx, db, settings, in, res.Breadcrumbs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.JSONLD = graph
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user