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

119
classes/alternates.go Normal file
View File

@@ -0,0 +1,119 @@
package classes
import (
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
)
// OGLocalesConfigKey maps locale codes to og:locale values (D-55); unset
// codes fall back to DefaultOGLocales, else <code>_<CODE>.
const OGLocalesConfigKey = "golem15.seo.og_locales"
// SearchURLConfigKey is the site search address with the
// {search_term_string} placeholder (P33-25); empty means no SearchAction.
const SearchURLConfigKey = "golem15.seo.search_url"
// DefaultOGLocales are the og:locale values of the common locales.
var DefaultOGLocales = map[string]string{"pl": "pl_PL", "en": "en_US", "de": "de_DE"}
// Alternate is one hreflang alternate of a page (D-55): a language tag or
// x-default and the absolute URL.
type Alternate struct {
Hreflang string `json:"hreflang"`
URL string `json:"url"`
}
// LangTag turns a locale code into its language tag: pl stays pl, pt_BR
// and pt-br become pt-BR (the region is kept when the locale has one).
func LangTag(locale string) string {
locale = strings.TrimSpace(strings.ReplaceAll(locale, "_", "-"))
lang, region, ok := strings.Cut(locale, "-")
if !ok || region == "" {
return strings.ToLower(lang)
}
return strings.ToLower(lang) + "-" + strings.ToUpper(region)
}
// OGLocale is the og:locale value of locale: the configured value, else
// the default table, else the language with its region (pt_BR) or the
// language doubled (fr_FR).
func (s *Service) OGLocale(locale string) string {
tag := LangTag(locale)
if tag == "" {
return ""
}
if cfg := s.Config(); cfg != nil {
for _, key := range []string{locale, tag, strings.ToLower(tag)} {
if v := strings.TrimSpace(cfg.String(OGLocalesConfigKey + "." + key)); v != "" {
return v
}
}
}
if v, ok := DefaultOGLocales[tag]; ok {
return v
}
lang, region, ok := strings.Cut(tag, "-")
if ok {
return lang + "_" + region
}
return lang + "_" + strings.ToUpper(lang)
}
// translatedLocales lists the locales of rec with a public address, in the
// record's order.
func translatedLocales(rec *pact.ContentRecord) []pact.ContentLocale {
if rec == nil {
return nil
}
var out []pact.ContentLocale
seen := map[string]bool{}
for _, l := range rec.Locales {
if strings.TrimSpace(l.URL) == "" || l.Locale == "" || seen[l.Locale] {
continue
}
seen[l.Locale] = true
out = append(out, l)
}
return out
}
// Alternates lists the hreflang alternates of a record (D-55 to D-57):
// each translated locale with its absolute URL, the page itself included,
// then x-default on the default locale's URL. A record translated into one
// locale only has none; a locale without a translation is absent.
func Alternates(site string, rec *pact.ContentRecord, defaultLocale string) []Alternate {
locales := translatedLocales(rec)
if len(locales) < 2 {
return []Alternate{}
}
out := make([]Alternate, 0, len(locales)+1)
xDefault := ""
for _, l := range locales {
u := AbsoluteURL(site, l.URL)
out = append(out, Alternate{Hreflang: LangTag(l.Locale), URL: u})
if l.Locale == defaultLocale {
xDefault = u
}
}
if xDefault != "" {
out = append(out, Alternate{Hreflang: "x-default", URL: xDefault})
}
return out
}
// OGLocaleAlternates are the og:locale:alternate values of a record: one
// per other translated locale.
func (s *Service) OGLocaleAlternates(rec *pact.ContentRecord, locale string) []string {
out := []string{}
locales := translatedLocales(rec)
if len(locales) < 2 {
return out
}
for _, l := range locales {
if l.Locale != locale {
out = append(out, s.OGLocale(l.Locale))
}
}
return out
}

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})
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"unicode/utf8"
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
@@ -64,8 +65,16 @@ type Result struct {
SEO *SEO `json:"seo"`
// Outbound is how the layer renders links to other sites (P33-16),
// present on ok answers.
Outbound *Outbound `json:"outbound,omitempty"`
Sources map[string]string `json:"-"`
Outbound *Outbound `json:"outbound,omitempty"`
// HTMLLang is the page's <html lang> (P33-21); Alternates its hreflang
// list, the page itself and x-default included (D-55 to D-57); JSONLD
// its one @graph (D-43); Breadcrumbs its trail, the same items as the
// BreadcrumbList node (D-86). All are set on ok answers only.
HTMLLang string `json:"html_lang,omitempty"`
Alternates []Alternate `json:"alternates,omitempty"`
JSONLD map[string]any `json:"jsonld,omitempty"`
Breadcrumbs []schema.Crumb `json:"breadcrumbs,omitempty"`
Sources map[string]string `json:"-"`
// TypeKey and RecordID name the record behind the URL, "" when none.
TypeKey string `json:"-"`
RecordID string `json:"-"`
@@ -118,6 +127,10 @@ type OpenGraph struct {
Description string `json:"description"`
Image string `json:"image"`
URL string `json:"url"`
// Locale is og:locale (pl_PL) and LocaleAlternates the og:locale of the
// record's other translated locales (D-55), set on ok answers.
Locale string `json:"locale"`
LocaleAlternates []string `json:"locale_alternates"`
}
// TwitterTag holds the twitter: values.
@@ -679,6 +692,16 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S
res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, locale, seo
res.Outbound = OutboundOf(settings)
// The sitemap filter (production) decides on robots and canonical
// only; it skips the structured data.
if !r.production {
if err := svc.structuredData(ctx, db, settings, &res, structuredIn{
key: key, id: res.RecordID, rec: rec, entry: entry, content: content,
locale: locale, path: path, url: self, home: home, siteName: svc.SiteName(ctx, settings, locale), seo: seo,
}); err != nil {
return Result{}, err
}
}
return res, nil
}

307
classes/schema/graph.go Normal file
View File

@@ -0,0 +1,307 @@
// Package schema builds the JSON-LD @graph of a page (D-43): one
// {"@context": "https://schema.org", "@graph": [...]} object whose nodes
// link to each other by @id (P33-22). It works on plain data only, so the
// resolver, the admin preview and the MCP tool share one builder.
package schema
import (
"context"
"strings"
"time"
)
// Context is the @context of every graph.
const Context = "https://schema.org"
// The schema.org types the builder generates or an editor may choose. No
// e-commerce type is offered (out of scope).
const (
TypeWebPage = "WebPage"
TypeAboutPage = "AboutPage"
TypeContactPage = "ContactPage"
TypeCollectionPage = "CollectionPage"
TypeProfilePage = "ProfilePage"
TypeBlogPosting = "BlogPosting"
TypeArticle = "Article"
TypeNewsArticle = "NewsArticle"
TypeService = "Service"
TypeWebSite = "WebSite"
TypeOrganization = "Organization"
TypePerson = "Person"
TypeBreadcrumbList = "BreadcrumbList"
TypeVideoObject = "VideoObject"
TypeLocalBusiness = "LocalBusiness"
)
// The content kinds the builder knows (pact.ContentKind* values).
const (
KindArticle = "article"
KindCollection = "collection"
KindProfile = "profile"
KindPage = "page"
)
// The registry page kinds whose default type is Service.
const (
VariantOffer = "offer"
VariantService = "service"
)
// The publisher types of the settings.
const (
PublisherOrganization = "organization"
PublisherPerson = "person"
)
// LocalTypes are the LocalBusiness types the settings offer (UI-SPEC C20).
var LocalTypes = []string{
"LocalBusiness", "ProfessionalService", "Store", "FoodEstablishment",
"HealthAndBeautyBusiness", "MedicalBusiness", "LegalService",
"HomeAndConstructionBusiness", "AutomotiveBusiness", "FinancialService",
}
// Crumb is one breadcrumb: its name, absolute URL and site path.
type Crumb struct {
Name string `json:"name"`
URL string `json:"url"`
Path string `json:"path"`
}
// Person is an author: a name and, when the author has a page, its
// absolute URL.
type Person struct {
Name string
URL string
}
// Video is an embedded video: its host and its id there.
type Video struct {
Provider string
ID string
}
// Hours is one opening-hours row: a day (mon … sun) and 24-hour times.
type Hours struct {
Day string `json:"day"`
Opens string `json:"opens"`
Closes string `json:"closes"`
}
// Local is the publisher's local business data (D-45).
type Local struct {
Type string
PriceRange string
Street string
PostalCode string
City string
Region string
Country string
Latitude *float64
Longitude *float64
Hours []Hours
}
// Publisher is who publishes the site (D-03, D-45): an organisation or a
// person, optionally a local business.
type Publisher struct {
Type string
Name string
Logo string
URL string
Email string
Phone string
SameAs []string
Local *Local
}
// ServiceFields are the editor's Service fields (UI-SPEC C17).
type ServiceFields struct {
ServiceType string `json:"serviceType,omitempty"`
AreaServed string `json:"areaServed,omitempty"`
Price string `json:"price,omitempty"`
Currency string `json:"currency,omitempty"`
}
// Input is what Build composes a page's graph from.
type Input struct {
// Site is the site's origin without a trailing slash.
Site string
// URL is the page's absolute URL (its canonical address).
URL string
// Lang is the page's language tag (pl, pl-PL).
Lang string
// Home is true for the site's home page.
Home bool
// Kind is the content kind (article, collection, profile, page), ""
// when the page has no record or the type names none.
Kind string
// Variant is the registry page kind (page, offer, service, landing).
Variant string
// Type is the editor's schema type, "" for the kind's default.
Type string
// Name and Description are the page's name and description.
Name string
Description string
// Headline is the article's headline, Name when empty.
Headline string
// Images are absolute image URLs, the featured one first.
Images []string
PublishedAt *time.Time
ModifiedAt *time.Time
Words *int
// Section is the article's section: its primary parent's name.
Section string
Authors []Person
Videos []Video
// Crumbs is the breadcrumb trail, home first, the page last.
Crumbs []Crumb
SiteName string
SearchURL string
Publisher Publisher
Service ServiceFields
// Profile is the person of a profile page; Name when empty.
Profile *Person
}
// DefaultType is the type a page of kind (and registry variant) gets when
// the editor chose none.
func DefaultType(kind, variant string) string {
switch kind {
case KindArticle:
return TypeBlogPosting
case KindCollection:
return TypeCollectionPage
case KindProfile:
return TypeProfilePage
}
if variant == VariantOffer || variant == VariantService {
return TypeService
}
return TypeWebPage
}
// AllowedTypes lists the types an editor may choose for kind (UI-SPEC
// C17): one fixed type for collections and profiles.
func AllowedTypes(kind string) []string {
switch kind {
case KindArticle:
return []string{TypeBlogPosting, TypeArticle, TypeNewsArticle}
case KindCollection:
return []string{TypeCollectionPage}
case KindProfile:
return []string{TypeProfilePage}
}
return []string{TypeWebPage, TypeAboutPage, TypeContactPage, TypeService}
}
// Allowed reports whether typ may be chosen for kind; "" always may.
func Allowed(kind, typ string) bool {
if typ == "" {
return true
}
for _, t := range AllowedTypes(kind) {
if t == typ {
return true
}
}
return false
}
// EffectiveType is the page's schema type: the editor's choice when it is
// allowed for the kind, else the default.
func EffectiveType(kind, variant, typ string) string {
if typ != "" && Allowed(kind, typ) {
return typ
}
return DefaultType(kind, variant)
}
// isArticle reports whether typ is an article type.
func isArticle(typ string) bool {
return typ == TypeBlogPosting || typ == TypeArticle || typ == TypeNewsArticle
}
// IDs are the @id values of a page (P33-22).
type IDs struct {
WebSite string
Publisher string
Logo string
WebPage string
Breadcrumb string
Article string
ItemList string
Profile string
Service string
}
// PageIDs returns the @id values of a page at url on site.
func PageIDs(site, url, publisherType string) IDs {
pub := site + "/#organization"
if publisherType == PublisherPerson {
pub = site + "/#person"
}
return IDs{
WebSite: site + "/#website",
Publisher: pub,
Logo: site + "/#logo",
WebPage: url + "#webpage",
Breadcrumb: url + "#breadcrumb",
Article: url + "#article",
ItemList: url + "#itemlist",
Profile: url + "#profile",
Service: url + "#service",
}
}
// Build composes the page's graph: the page node (WebPage or its subtype),
// its BreadcrumbList when the trail has two items or more, the WebSite and
// the publisher, and per kind the article with its videos, the profile's
// person or the service.
func Build(_ context.Context, in Input) (map[string]any, error) {
typ := EffectiveType(in.Kind, in.Variant, in.Type)
ids := PageIDs(in.Site, in.URL, in.Publisher.Type)
var graph []map[string]any
page := webPageNode(in, typ, ids)
graph = append(graph, page)
if isArticle(typ) {
graph = append(graph, articleNode(in, typ, ids))
}
switch typ {
case TypeProfilePage:
graph = append(graph, profileNode(in, ids))
case TypeService:
graph = append(graph, serviceNode(in, ids))
}
if crumbs := breadcrumbNode(in.Crumbs, ids); crumbs != nil {
graph = append(graph, crumbs)
}
graph = append(graph, videoNodes(in, ids)...)
graph = append(graph, webSiteNode(in, ids))
if pub := PublisherNode(in.Site, in.Publisher, in.SiteName); pub != nil {
graph = append(graph, pub)
}
nodes := make([]any, len(graph))
for i, n := range graph {
nodes[i] = n
}
return map[string]any{"@context": Context, "@graph": nodes}, nil
}
// put sets key to a non-empty string value.
func put(n map[string]any, key, value string) {
if v := strings.TrimSpace(value); v != "" {
n[key] = v
}
}
// ref is an @id reference.
func ref(id string) map[string]any { return map[string]any{"@id": id} }
// date formats a time for schema.org (RFC 3339, UTC).
func date(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}

340
classes/schema/nodes.go Normal file
View File

@@ -0,0 +1,340 @@
package schema
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// webPageNode is the page node: WebPage or the subtype the effective type
// asks for, part of the WebSite, pointing at its breadcrumb trail and, per
// type, at its main entity.
func webPageNode(in Input, typ string, ids IDs) map[string]any {
pageType := TypeWebPage
switch typ {
case TypeAboutPage, TypeContactPage, TypeCollectionPage, TypeProfilePage:
pageType = typ
}
n := map[string]any{
"@type": pageType,
"@id": ids.WebPage,
"url": in.URL,
"isPartOf": ref(ids.WebSite),
}
put(n, "name", in.Name)
put(n, "description", in.Description)
put(n, "inLanguage", in.Lang)
if len(in.Crumbs) >= 2 {
n["breadcrumb"] = ref(ids.Breadcrumb)
}
if len(in.Images) > 0 {
n["primaryImageOfPage"] = map[string]any{"@type": "ImageObject", "url": in.Images[0]}
}
if d := date(in.PublishedAt); d != "" {
n["datePublished"] = d
}
if d := date(in.ModifiedAt); d != "" {
n["dateModified"] = d
}
switch typ {
case TypeCollectionPage:
n["mainEntity"] = ref(ids.ItemList)
case TypeProfilePage:
n["mainEntity"] = ref(ids.Profile)
case TypeService:
n["mainEntity"] = ref(ids.Service)
case TypeContactPage, TypeAboutPage:
n["about"] = ref(ids.Publisher)
if typ == TypeContactPage {
n["mainEntity"] = ref(ids.Publisher)
}
}
if in.Home {
n["about"] = ref(ids.Publisher)
}
return n
}
// articleNode is the article of an article page: headline, authors,
// publisher, images, dates, word count and section.
func articleNode(in Input, typ string, ids IDs) map[string]any {
n := map[string]any{
"@type": typ,
"@id": ids.Article,
"isPartOf": ref(ids.WebPage),
"mainEntityOfPage": ref(ids.WebPage),
"publisher": ref(ids.Publisher),
}
headline := in.Headline
if strings.TrimSpace(headline) == "" {
headline = in.Name
}
put(n, "headline", headline)
put(n, "description", in.Description)
put(n, "inLanguage", in.Lang)
put(n, "articleSection", in.Section)
if d := date(in.PublishedAt); d != "" {
n["datePublished"] = d
}
if d := date(in.ModifiedAt); d != "" {
n["dateModified"] = d
} else if d := date(in.PublishedAt); d != "" {
n["dateModified"] = d
}
if in.Words != nil && *in.Words > 0 {
n["wordCount"] = *in.Words
}
if len(in.Images) > 0 {
imgs := make([]any, len(in.Images))
for i, u := range in.Images {
imgs[i] = u
}
n["image"] = imgs
}
var authors []any
for _, a := range in.Authors {
if strings.TrimSpace(a.Name) == "" {
continue
}
p := map[string]any{"@type": TypePerson, "name": strings.TrimSpace(a.Name)}
if a.URL != "" {
p["url"] = a.URL
p["@id"] = a.URL + "#profile"
}
authors = append(authors, p)
}
if len(authors) > 0 {
n["author"] = authors
}
return n
}
// profileNode is the person of a profile page.
func profileNode(in Input, ids IDs) map[string]any {
n := map[string]any{"@type": TypePerson, "@id": ids.Profile, "url": in.URL, "mainEntityOfPage": ref(ids.WebPage)}
name := in.Name
if in.Profile != nil && strings.TrimSpace(in.Profile.Name) != "" {
name = in.Profile.Name
}
put(n, "name", name)
put(n, "description", in.Description)
if len(in.Images) > 0 {
n["image"] = in.Images[0]
}
return n
}
// serviceNode is the service or offer of a page: provided by the
// publisher, with the area served and an optional offer (price and
// currency).
func serviceNode(in Input, ids IDs) map[string]any {
n := map[string]any{
"@type": TypeService,
"@id": ids.Service,
"url": in.URL,
"provider": ref(ids.Publisher),
"mainEntityOfPage": ref(ids.WebPage),
}
put(n, "name", in.Name)
put(n, "description", in.Description)
put(n, "serviceType", in.Service.ServiceType)
if areas := splitList(in.Service.AreaServed); len(areas) > 0 {
if len(areas) == 1 {
n["areaServed"] = areas[0]
} else {
list := make([]any, len(areas))
for i, a := range areas {
list[i] = a
}
n["areaServed"] = list
}
}
if len(in.Images) > 0 {
n["image"] = in.Images[0]
}
if price := NormalizePrice(in.Service.Price); price != "" {
offer := map[string]any{"@type": "Offer", "price": price, "url": in.URL}
put(offer, "priceCurrency", strings.ToUpper(in.Service.Currency))
n["offers"] = offer
}
return n
}
// NormalizePrice turns an editor's price (150, 149,99, 149.99) into its
// schema.org form, "" when it is not a price.
func NormalizePrice(s string) string {
s = strings.ReplaceAll(strings.TrimSpace(s), ",", ".")
if s == "" {
return ""
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || f < 0 || strings.ContainsAny(s, "eE+-") {
return ""
}
return s
}
// splitList splits a comma-separated list into its trimmed items.
func splitList(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// breadcrumbNode is the BreadcrumbList of a trail of two items or more
// (Google's minimum); nil otherwise.
func breadcrumbNode(crumbs []Crumb, ids IDs) map[string]any {
if len(crumbs) < 2 {
return nil
}
items := make([]any, len(crumbs))
for i, c := range crumbs {
items[i] = map[string]any{"@type": "ListItem", "position": i + 1, "name": c.Name, "item": c.URL}
}
return map[string]any{"@type": TypeBreadcrumbList, "@id": ids.Breadcrumb, "itemListElement": items}
}
// videoNodes is one VideoObject per YouTube video (D-43).
func videoNodes(in Input, ids IDs) []map[string]any {
var out []map[string]any
n := 0
for _, v := range in.Videos {
id := strings.TrimSpace(v.ID)
if !strings.EqualFold(v.Provider, "youtube") || id == "" {
continue
}
n++
esc := url.PathEscape(id)
node := map[string]any{
"@type": TypeVideoObject,
"@id": fmt.Sprintf("%s#video-%d", in.URL, n),
"embedUrl": "https://www.youtube.com/embed/" + esc,
"url": "https://www.youtube.com/watch?v=" + url.QueryEscape(id),
"thumbnailUrl": []any{"https://i.ytimg.com/vi/" + esc + "/hqdefault.jpg"},
"isPartOf": ref(ids.WebPage),
}
name := in.Headline
if strings.TrimSpace(name) == "" {
name = in.Name
}
put(node, "name", name)
desc := in.Description
if strings.TrimSpace(desc) == "" {
desc = name
}
put(node, "description", desc)
if d := date(in.PublishedAt); d != "" {
node["uploadDate"] = d
}
out = append(out, node)
}
return out
}
// webSiteNode is the WebSite, published by the publisher, with a
// SearchAction when the site has a search address.
func webSiteNode(in Input, ids IDs) map[string]any {
n := map[string]any{
"@type": TypeWebSite,
"@id": ids.WebSite,
"url": in.Site + "/",
"publisher": ref(ids.Publisher),
}
put(n, "name", in.SiteName)
put(n, "inLanguage", in.Lang)
if search := strings.TrimSpace(in.SearchURL); search != "" && strings.Contains(search, "{search_term_string}") {
target := search
if strings.HasPrefix(search, "/") {
target = in.Site + search
}
n["potentialAction"] = map[string]any{
"@type": "SearchAction",
"target": map[string]any{"@type": "EntryPoint", "urlTemplate": target},
"query-input": "required name=search_term_string",
}
}
return n
}
// dayNames maps the settings' day codes to schema.org day names.
var dayNames = map[string]string{
"mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday",
"fri": "Friday", "sat": "Saturday", "sun": "Sunday",
}
// PublisherNode is the publisher's node: an Organization (or the local
// business type) or a Person, named after the site when the settings name
// none. It is nil when there is no name at all.
func PublisherNode(site string, p Publisher, siteName string) map[string]any {
name := strings.TrimSpace(p.Name)
if name == "" {
name = strings.TrimSpace(siteName)
}
if name == "" {
return nil
}
ids := PageIDs(site, site+"/", p.Type)
home := strings.TrimSpace(p.URL)
if home == "" {
home = site + "/"
}
n := map[string]any{"@id": ids.Publisher, "name": name, "url": home}
if p.Type == PublisherPerson {
n["@type"] = TypePerson
if logo := strings.TrimSpace(p.Logo); logo != "" {
n["image"] = map[string]any{"@type": "ImageObject", "url": logo}
}
} else {
n["@type"] = TypeOrganization
if logo := strings.TrimSpace(p.Logo); logo != "" {
n["logo"] = map[string]any{"@type": "ImageObject", "@id": ids.Logo, "url": logo, "contentUrl": logo}
n["image"] = ref(ids.Logo)
}
}
put(n, "email", p.Email)
put(n, "telephone", p.Phone)
if len(p.SameAs) > 0 {
list := make([]any, len(p.SameAs))
for i, s := range p.SameAs {
list[i] = s
}
n["sameAs"] = list
}
if l := p.Local; l != nil && p.Type != PublisherPerson {
typ := strings.TrimSpace(l.Type)
if typ == "" {
typ = TypeLocalBusiness
}
n["@type"] = typ
addr := map[string]any{"@type": "PostalAddress"}
put(addr, "streetAddress", l.Street)
put(addr, "postalCode", l.PostalCode)
put(addr, "addressLocality", l.City)
put(addr, "addressRegion", l.Region)
put(addr, "addressCountry", strings.ToUpper(l.Country))
if len(addr) > 1 {
n["address"] = addr
}
if l.Latitude != nil && l.Longitude != nil {
n["geo"] = map[string]any{"@type": "GeoCoordinates", "latitude": *l.Latitude, "longitude": *l.Longitude}
}
var hours []any
for _, h := range l.Hours {
day, ok := dayNames[h.Day]
if !ok || h.Opens == "" || h.Closes == "" {
continue
}
hours = append(hours, map[string]any{"@type": "OpeningHoursSpecification", "dayOfWeek": day, "opens": h.Opens, "closes": h.Closes})
}
if len(hours) > 0 {
n["openingHoursSpecification"] = hours
}
put(n, "priceRange", l.PriceRange)
}
return n
}

222
classes/structured.go Normal file
View 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
}