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:
119
classes/alternates.go
Normal file
119
classes/alternates.go
Normal 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
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})
|
||||
}
|
||||
@@ -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
307
classes/schema/graph.go
Normal 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
340
classes/schema/nodes.go
Normal 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
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
|
||||
}
|
||||
@@ -32,3 +32,15 @@ notfound_max_rows: 10000
|
||||
# POST /_seo/api/v1/registry/routes (the page registry). Empty uses
|
||||
# notfound_token; both empty turn the report off (it answers 404).
|
||||
route_report_token: ""
|
||||
#
|
||||
# og_locales maps locale codes to their og:locale values (the resolve
|
||||
# answer's seo.og.locale and locale_alternates). Codes not listed use
|
||||
# pl_PL, en_US and de_DE for pl, en and de, else the language with its
|
||||
# region (pt-BR: pt_BR) or the language doubled (fr: fr_FR):
|
||||
# og_locales: {pl: pl_PL, en: en_GB}
|
||||
#
|
||||
# search_url is the site search address with the {search_term_string}
|
||||
# placeholder, for example /szukaj?q={search_term_string}. When set, the
|
||||
# WebSite node of the JSON-LD graph carries a SearchAction. Empty means no
|
||||
# SearchAction:
|
||||
# search_url: "/szukaj?q={search_term_string}"
|
||||
|
||||
@@ -148,6 +148,7 @@ settings:
|
||||
verify_filename_invalid: Use a plain file name ending in .html, .txt or .xml, without slashes, e.g. google1234.html.
|
||||
verify_filename_taken: Another verification file already uses this name.
|
||||
template_unknown: Unknown placeholder “:placeholder”. Use one from the table below the templates.
|
||||
breadcrumb_home_default: "Home"
|
||||
rules:
|
||||
new: New URL rule
|
||||
list_title: URL rules
|
||||
|
||||
@@ -150,6 +150,7 @@ settings:
|
||||
verify_filename_invalid: Użyj samej nazwy pliku z końcówką .html, .txt lub .xml, bez ukośników, np. google1234.html.
|
||||
verify_filename_taken: Ten plik weryfikacyjny już istnieje.
|
||||
template_unknown: Nieznany znacznik „:placeholder”. Użyj jednego z tabeli pod szablonami.
|
||||
breadcrumb_home_default: "Strona główna"
|
||||
rules:
|
||||
new: Nowa reguła adresu
|
||||
list_title: Reguły adresów
|
||||
|
||||
@@ -63,6 +63,23 @@ type VerifyFile struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// The publisher types (D-45) and the page address forms (D-40).
|
||||
const (
|
||||
PublisherOrganization = "organization"
|
||||
PublisherPerson = "person"
|
||||
PageFormQuery = "query"
|
||||
PageFormPath = "path"
|
||||
DefaultLocalType = "LocalBusiness"
|
||||
)
|
||||
|
||||
// OpeningHours is one opening-hours row of a local business: a day (mon
|
||||
// … sun) and its opening and closing time (24-hour HH:MM).
|
||||
type OpeningHours struct {
|
||||
Day string `json:"day"`
|
||||
Opens string `json:"opens"`
|
||||
Closes string `json:"closes"`
|
||||
}
|
||||
|
||||
// DefaultQueryParams is the seeded D-07 list.
|
||||
func DefaultQueryParams() []QueryParam {
|
||||
return []QueryParam{
|
||||
@@ -105,6 +122,38 @@ type Settings struct {
|
||||
LinksNewTab bool `gorm:"column:links_new_tab"`
|
||||
NofollowDomains string `gorm:"column:nofollow_domains"`
|
||||
SponsoredDomains string `gorm:"column:sponsored_domains"`
|
||||
// BreadcrumbHome is the home crumb's name (translatable; empty: the
|
||||
// per-locale default copy).
|
||||
BreadcrumbHome string `gorm:"column:breadcrumb_home"`
|
||||
// PageForm is the paginated address form, query (?page=N) or path
|
||||
// (/<segment>/N); PageSegment the path segment, translatable per
|
||||
// locale; the suffixes are added on page 2 and later (D-40, D-41).
|
||||
PageForm string `gorm:"column:page_form"`
|
||||
PageSegment string `gorm:"column:page_segment"`
|
||||
PageTitleSuffix string `gorm:"column:page_title_suffix"`
|
||||
PageDescriptionSuffix string `gorm:"column:page_description_suffix"`
|
||||
// The publisher of the site (D-45): organization or person, its name
|
||||
// (translatable; empty: the site name), logo or photo, website, email,
|
||||
// phone and sameAs profiles (one https URL per line).
|
||||
PublisherType string `gorm:"column:publisher_type"`
|
||||
PublisherName string `gorm:"column:publisher_name"`
|
||||
PublisherLogo string `gorm:"column:publisher_logo"`
|
||||
PublisherURL string `gorm:"column:publisher_url"`
|
||||
PublisherEmail string `gorm:"column:publisher_email"`
|
||||
PublisherPhone string `gorm:"column:publisher_phone"`
|
||||
PublisherSameAs string `gorm:"column:publisher_same_as"`
|
||||
// The local business data of an organisation publisher (D-45).
|
||||
LocalEnabled bool `gorm:"column:local_enabled"`
|
||||
LocalType string `gorm:"column:local_type"`
|
||||
LocalPriceRange string `gorm:"column:local_price_range"`
|
||||
LocalStreet string `gorm:"column:local_street"`
|
||||
LocalPostalCode string `gorm:"column:local_postal_code"`
|
||||
LocalCity string `gorm:"column:local_city"`
|
||||
LocalRegion string `gorm:"column:local_region"`
|
||||
LocalCountry string `gorm:"column:local_country"`
|
||||
LocalLatitude string `gorm:"column:local_latitude"`
|
||||
LocalLongitude string `gorm:"column:local_longitude"`
|
||||
LocalHours lagoon.Jsonable[[]OpeningHours] `gorm:"column:local_hours"`
|
||||
// RoutesReportedAt is the time of the newest route report (D-33),
|
||||
// written by the report only.
|
||||
RoutesReportedAt *time.Time `gorm:"column:routes_reported_at"`
|
||||
@@ -142,9 +191,15 @@ func DefaultSettings() Settings {
|
||||
URLHost: HostKeep,
|
||||
URLSlash: SlashKeep,
|
||||
LinksNewTab: true,
|
||||
PageForm: PageFormQuery,
|
||||
PublisherType: PublisherOrganization,
|
||||
LocalType: DefaultLocalType,
|
||||
}
|
||||
}
|
||||
|
||||
// Hours returns the local business's opening hours.
|
||||
func (s Settings) Hours() []OpeningHours { return s.LocalHours.Get() }
|
||||
|
||||
// Params returns the query-parameter list.
|
||||
func (s Settings) Params() []QueryParam { return s.QueryParams.Get() }
|
||||
|
||||
|
||||
367
structured_data_test.go
Normal file
367
structured_data_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package seo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// The fake content plugin acme.blog: posts (kind article) under
|
||||
// categories (kind collection) with authors (kind profile), all in memory,
|
||||
// describing the 33-01 content facts (first publication, parents, authors,
|
||||
// images, videos). It knows nothing about the SEO plugin.
|
||||
|
||||
const (
|
||||
blogPluginID = "acme.magazine"
|
||||
postTypeKey = "acme-story"
|
||||
postModel = "acme_magazine_stories"
|
||||
categoryTypeKey = "acme-section"
|
||||
categoryModel = "acme_magazine_sections"
|
||||
authorTypeKey = "acme-writer"
|
||||
authorModel = "acme_magazine_writers"
|
||||
)
|
||||
|
||||
// blogRecords are the fake records per type key; tests may change them.
|
||||
var (
|
||||
blogMu sync.Mutex
|
||||
blogRecords map[string][]pact.ContentRecord
|
||||
)
|
||||
|
||||
func intPtr(n int) *int { return &n }
|
||||
|
||||
func timePtr(s string) *time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// resetBlog seeds the fake records.
|
||||
func resetBlog() {
|
||||
blogMu.Lock()
|
||||
defer blogMu.Unlock()
|
||||
blogRecords = map[string][]pact.ContentRecord{
|
||||
postTypeKey: {
|
||||
{
|
||||
ID: "1", Published: true,
|
||||
PublishedAt: timePtr("2026-10-01T08:00:00Z"),
|
||||
LastMod: timePtr("2026-10-05T12:30:00Z"),
|
||||
Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "1"}, {Type: categoryTypeKey, ID: "2"}},
|
||||
Authors: []pact.ContentPerson{{Name: "Jan Kowalski", Ref: pact.ContentRef{Type: authorTypeKey, ID: "7"}}},
|
||||
Locales: []pact.ContentLocale{
|
||||
{
|
||||
Locale: "en", URL: "/blog/first-post", Title: "First post", Description: "About the first post",
|
||||
Image: "/media/first.jpg", Words: intPtr(420),
|
||||
Images: []pact.ContentImage{{URL: "/media/first.jpg", Alt: "A mushroom"}, {URL: "/media/deco.png", Decorative: true}},
|
||||
Videos: []pact.ContentVideo{{Provider: "youtube", ID: "dQw4w9WgXcQ"}},
|
||||
},
|
||||
{Locale: "pl", URL: "/pl/blog/pierwszy-wpis", Title: "Pierwszy wpis", Description: "O pierwszym wpisie", Words: intPtr(410)},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "2", Published: true, PublishedAt: timePtr("2026-10-02T08:00:00Z"),
|
||||
Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/solo", Title: "Solo"}},
|
||||
},
|
||||
{
|
||||
ID: "3", Published: true, PublishedAt: timePtr("2026-10-03T08:00:00Z"),
|
||||
Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "3"}},
|
||||
Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/nested", Title: "Nested"}},
|
||||
},
|
||||
},
|
||||
categoryTypeKey: {
|
||||
{ID: "1", Published: true, Locales: []pact.ContentLocale{
|
||||
{Locale: "en", URL: "/blog/category/recipes", Title: "Recipes"},
|
||||
{Locale: "pl", URL: "/pl/blog/kategoria/przepisy", Title: "Przepisy"},
|
||||
}},
|
||||
{ID: "2", Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/category/mushrooms", Title: "Mushrooms"}}},
|
||||
{ID: "3", Published: true, Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "4"}}, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/category/soups", Title: "Soups"}}},
|
||||
{ID: "4", Published: false, Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "1"}}, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/category/hidden", Title: "Hidden"}}},
|
||||
},
|
||||
authorTypeKey: {
|
||||
{ID: "7", Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/author/jan", Title: "Jan Kowalski", Image: "/media/jan.jpg"}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
resetBlog()
|
||||
party.Register(&magazinePlugin{})
|
||||
}
|
||||
|
||||
// magazinePlugin is acme.blog.
|
||||
type magazinePlugin struct{}
|
||||
|
||||
func (*magazinePlugin) ID() string { return blogPluginID }
|
||||
func (*magazinePlugin) Requires() []string { return nil }
|
||||
func (*magazinePlugin) Register(*backpack.App) error { return nil }
|
||||
func (*magazinePlugin) Boot(*backpack.App) error { return nil }
|
||||
func (*magazinePlugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{
|
||||
blogType(postTypeKey, "Posts", postModel, "/blog/:slug", pact.ContentKindArticle),
|
||||
blogType(categoryTypeKey, "Categories", categoryModel, "/blog/category/:slug", pact.ContentKindCollection),
|
||||
blogType(authorTypeKey, "Authors", authorModel, "/blog/author/:slug", pact.ContentKindProfile),
|
||||
}
|
||||
}
|
||||
|
||||
var _ pact.HasMenuItemTypes = (*magazinePlugin)(nil)
|
||||
|
||||
// blogRecord returns a copy of a fake record.
|
||||
func blogRecord(key, id string) *pact.ContentRecord {
|
||||
blogMu.Lock()
|
||||
defer blogMu.Unlock()
|
||||
for _, r := range blogRecords[key] {
|
||||
if r.ID == id {
|
||||
c := r
|
||||
return &c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// blogType is one in-memory content type of acme.blog.
|
||||
func blogType(key, label, model, pattern, kind string) pact.MenuItemType {
|
||||
return pact.MenuItemType{
|
||||
Key: key,
|
||||
Label: label,
|
||||
DefaultPattern: pattern,
|
||||
Placeholders: []string{"slug"},
|
||||
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Content: &pact.ContentDescriptor{
|
||||
Model: model,
|
||||
Kind: kind,
|
||||
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
return blogRecord(key, in.ID), nil
|
||||
},
|
||||
Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
blogMu.Lock()
|
||||
defer blogMu.Unlock()
|
||||
out := pact.ContentPage{}
|
||||
if in.After == "" {
|
||||
out.Records = append(out.Records, blogRecords[key]...)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||
blogMu.Lock()
|
||||
defer blogMu.Unlock()
|
||||
for _, r := range blogRecords[key] {
|
||||
for _, l := range r.Locales {
|
||||
if l.URL == in.Path && r.Published {
|
||||
return &pact.ContentMatchResult{ID: r.ID, Locale: l.Locale, Exact: true}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// bootBlog boots acme.blog with the SEO plugin, with the publisher
|
||||
// Organization "Acme".
|
||||
func bootBlog(t *testing.T, extraEnv ...string) *testEnv {
|
||||
t.Helper()
|
||||
resetBlog()
|
||||
env := bootPlugins(t, []string{"acme.pages", blogPluginID, "golem15.seo"}, extraEnv...)
|
||||
if err := env.db.Exec(`UPDATE golem15_seo_settings SET publisher_type = 'organization', publisher_name = 'Acme', site_name = 'Acme Blog' WHERE id = 1`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// graphNodes returns the resolve answer's @graph nodes keyed by @type.
|
||||
func graphNodes(t *testing.T, res map[string]any) map[string]map[string]any {
|
||||
t.Helper()
|
||||
jsonld, ok := res["jsonld"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("no jsonld in %v", res)
|
||||
}
|
||||
if jsonld["@context"] != "https://schema.org" {
|
||||
t.Fatalf("@context = %v", jsonld["@context"])
|
||||
}
|
||||
list, ok := jsonld["@graph"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("no @graph in %v", jsonld)
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
for _, raw := range list {
|
||||
n := raw.(map[string]any)
|
||||
typ, _ := n["@type"].(string)
|
||||
if _, dup := out[typ]; dup {
|
||||
t.Fatalf("two %s nodes", typ)
|
||||
}
|
||||
out[typ] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// idOf returns the @id of a reference value.
|
||||
func idOf(v any) string {
|
||||
m, _ := v.(map[string]any)
|
||||
s, _ := m["@id"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// asJSON compacts v.
|
||||
func asJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestGraphTracer(t *testing.T) {
|
||||
env := bootBlog(t)
|
||||
post := siteURL + "/blog/first-post"
|
||||
res := env.resolve(t, "/blog/first-post")
|
||||
if res["action"] != "ok" {
|
||||
t.Fatalf("action = %v", res)
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(res, "", " ")
|
||||
t.Logf("resolve answer:\n%s", pretty)
|
||||
|
||||
nodes := graphNodes(t, res)
|
||||
for _, typ := range []string{"WebPage", "BlogPosting", "BreadcrumbList", "VideoObject", "WebSite", "Organization"} {
|
||||
if nodes[typ] == nil {
|
||||
t.Fatalf("no %s node in %s", typ, asJSON(t, res["jsonld"]))
|
||||
}
|
||||
}
|
||||
page, article := nodes["WebPage"], nodes["BlogPosting"]
|
||||
if page["@id"] != post+"#webpage" || idOf(page["isPartOf"]) != siteURL+"/#website" || idOf(page["breadcrumb"]) != post+"#breadcrumb" {
|
||||
t.Fatalf("WebPage = %v", page)
|
||||
}
|
||||
if article["@id"] != post+"#article" || article["headline"] != "First post" || idOf(article["mainEntityOfPage"]) != post+"#webpage" || idOf(article["publisher"]) != siteURL+"/#organization" {
|
||||
t.Fatalf("BlogPosting = %v", article)
|
||||
}
|
||||
if article["datePublished"] != "2026-10-01T08:00:00Z" || article["dateModified"] != "2026-10-05T12:30:00Z" || article["wordCount"] != float64(420) || article["articleSection"] != "Recipes" {
|
||||
t.Fatalf("BlogPosting facts = %v", article)
|
||||
}
|
||||
authors, _ := article["author"].([]any)
|
||||
if len(authors) != 1 || authors[0].(map[string]any)["name"] != "Jan Kowalski" || authors[0].(map[string]any)["url"] != siteURL+"/blog/author/jan" {
|
||||
t.Fatalf("author = %v", article["author"])
|
||||
}
|
||||
if images, _ := article["image"].([]any); len(images) != 1 || images[0] != siteURL+"/media/first.jpg" {
|
||||
t.Fatalf("image = %v (the decorative image stays out)", article["image"])
|
||||
}
|
||||
video := nodes["VideoObject"]
|
||||
if video["@id"] != post+"#video-1" || video["embedUrl"] != "https://www.youtube.com/embed/dQw4w9WgXcQ" || video["uploadDate"] != "2026-10-01T08:00:00Z" || video["name"] != "First post" {
|
||||
t.Fatalf("VideoObject = %v", video)
|
||||
}
|
||||
if org := nodes["Organization"]; org["@id"] != siteURL+"/#organization" || org["name"] != "Acme" {
|
||||
t.Fatalf("Organization = %v", org)
|
||||
}
|
||||
if site := nodes["WebSite"]; site["@id"] != siteURL+"/#website" || idOf(site["publisher"]) != siteURL+"/#organization" || site["name"] != "Acme Blog" {
|
||||
t.Fatalf("WebSite = %v", site)
|
||||
}
|
||||
if _, ok := nodes["WebSite"]["potentialAction"]; ok {
|
||||
t.Fatal("SearchAction without search_url")
|
||||
}
|
||||
|
||||
// The breadcrumbs of the answer are the BreadcrumbList items.
|
||||
wantCrumbs := `[{"name":"Home","path":"/","url":"https://www.example.test/"},{"name":"Recipes","path":"/blog/category/recipes","url":"https://www.example.test/blog/category/recipes"},{"name":"First post","path":"/blog/first-post","url":"https://www.example.test/blog/first-post"}]`
|
||||
if got := asJSON(t, res["breadcrumbs"]); got != wantCrumbs {
|
||||
t.Fatalf("breadcrumbs = %s\nwant %s", got, wantCrumbs)
|
||||
}
|
||||
items, _ := nodes["BreadcrumbList"]["itemListElement"].([]any)
|
||||
crumbs := res["breadcrumbs"].([]any)
|
||||
if len(items) != len(crumbs) || nodes["BreadcrumbList"]["@id"] != post+"#breadcrumb" {
|
||||
t.Fatalf("BreadcrumbList = %v", nodes["BreadcrumbList"])
|
||||
}
|
||||
for i, raw := range items {
|
||||
item, crumb := raw.(map[string]any), crumbs[i].(map[string]any)
|
||||
if item["position"] != float64(i+1) || item["name"] != crumb["name"] || item["item"] != crumb["url"] {
|
||||
t.Fatalf("item %d = %v, crumb %v", i, item, crumb)
|
||||
}
|
||||
}
|
||||
|
||||
// hreflang from one source: en, pl and x-default on the default locale.
|
||||
wantAlternates := `[{"hreflang":"en","url":"https://www.example.test/blog/first-post"},{"hreflang":"pl","url":"https://www.example.test/pl/blog/pierwszy-wpis"},{"hreflang":"x-default","url":"https://www.example.test/blog/first-post"}]`
|
||||
if got := asJSON(t, res["alternates"]); got != wantAlternates {
|
||||
t.Fatalf("alternates = %s", got)
|
||||
}
|
||||
og := res["seo"].(map[string]any)["og"].(map[string]any)
|
||||
if res["html_lang"] != "en" || og["locale"] != "en_US" || asJSON(t, og["locale_alternates"]) != `["pl_PL"]` {
|
||||
t.Fatalf("html_lang %v, og %v", res["html_lang"], og)
|
||||
}
|
||||
|
||||
t.Run("TestAlternates", func(t *testing.T) {
|
||||
pl := env.resolve(t, "/pl/blog/pierwszy-wpis")
|
||||
if got := asJSON(t, pl["alternates"]); got != wantAlternates {
|
||||
t.Fatalf("pl alternates = %s", got)
|
||||
}
|
||||
og := pl["seo"].(map[string]any)["og"].(map[string]any)
|
||||
if pl["html_lang"] != "pl" || og["locale"] != "pl_PL" || asJSON(t, og["locale_alternates"]) != `["en_US"]` {
|
||||
t.Fatalf("pl html_lang %v, og %v", pl["html_lang"], og)
|
||||
}
|
||||
solo := env.resolve(t, "/blog/solo")
|
||||
if _, ok := solo["alternates"]; ok {
|
||||
t.Fatalf("single-locale record has alternates: %v", solo["alternates"])
|
||||
}
|
||||
if og := solo["seo"].(map[string]any)["og"].(map[string]any); asJSON(t, og["locale_alternates"]) != `[]` {
|
||||
t.Fatalf("single-locale og alternates = %v", og["locale_alternates"])
|
||||
}
|
||||
missing := env.resolve(t, "/blog/missing")
|
||||
for _, key := range []string{"html_lang", "alternates", "jsonld", "breadcrumbs"} {
|
||||
if _, ok := missing[key]; ok {
|
||||
t.Fatalf("not_found answer carries %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TestBreadcrumbs", func(t *testing.T) {
|
||||
pl := env.resolve(t, "/pl/blog/pierwszy-wpis")
|
||||
want := `[{"name":"Strona główna","path":"/pl","url":"https://www.example.test/pl"},{"name":"Przepisy","path":"/pl/blog/kategoria/przepisy","url":"https://www.example.test/pl/blog/kategoria/przepisy"},{"name":"Pierwszy wpis","path":"/pl/blog/pierwszy-wpis","url":"https://www.example.test/pl/blog/pierwszy-wpis"}]`
|
||||
if got := asJSON(t, pl["breadcrumbs"]); got != want {
|
||||
t.Fatalf("pl breadcrumbs = %s", got)
|
||||
}
|
||||
// The parent chain is followed recursively; an unpublished parent is
|
||||
// left out but its own parent is kept.
|
||||
nested := env.resolve(t, "/blog/nested")
|
||||
if got := crumbNames(nested); got != "Home > Recipes > Soups > Nested" {
|
||||
t.Fatalf("nested trail = %s", got)
|
||||
}
|
||||
// A stored primary parent wins over the first parent.
|
||||
if err := env.db.Exec(`INSERT INTO golem15_seo_entries (model_type, record_id, locale, type_key, data, created_at, updated_at) VALUES (?, '1', 'en', ?, ?, NOW(), NOW())`,
|
||||
postModel, postTypeKey, `{"primary_parent":"acme-section:2"}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chosen := env.resolve(t, "/blog/first-post")
|
||||
if got := crumbNames(chosen); got != "Home > Mushrooms > First post" {
|
||||
t.Fatalf("primary parent trail = %s", got)
|
||||
}
|
||||
if section := graphNodes(t, chosen)["BlogPosting"]["articleSection"]; section != "Mushrooms" {
|
||||
t.Fatalf("articleSection = %v", section)
|
||||
}
|
||||
// The home page's trail is the home crumb alone, without a
|
||||
// BreadcrumbList node.
|
||||
home := env.resolve(t, "/")
|
||||
if got := crumbNames(home); got != "Home" {
|
||||
t.Fatalf("home trail = %s", got)
|
||||
}
|
||||
if graphNodes(t, home)["BreadcrumbList"] != nil {
|
||||
t.Fatal("home page has a BreadcrumbList")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// crumbNames joins the answer's crumb names.
|
||||
func crumbNames(res map[string]any) string {
|
||||
list, _ := res["breadcrumbs"].([]any)
|
||||
var names []string
|
||||
for _, raw := range list {
|
||||
names = append(names, raw.(map[string]any)["name"].(string))
|
||||
}
|
||||
return strings.Join(names, " > ")
|
||||
}
|
||||
65
updates/202610280004_add_golem15_seo_publisher_pagination.go
Normal file
65
updates/202610280004_add_golem15_seo_publisher_pagination.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// addPublisherPagination adds the settings columns of the structured data,
|
||||
// breadcrumb and pagination plan (33-06): the home crumb name, the page
|
||||
// address form and its per-locale segment and suffixes, the publisher
|
||||
// (D-45) and its local business data (opening hours as a JSON list). The
|
||||
// translatable text columns (breadcrumb_home, page_segment, the suffixes,
|
||||
// publisher_name) hold the default locale; the others live with the
|
||||
// translation writer, as site_name does. Empty texts mean the per-locale
|
||||
// default copy.
|
||||
var addPublisherPagination = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610280004_add_golem15_seo_publisher_pagination",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_seo_settings
|
||||
ADD COLUMN breadcrumb_home TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN page_form TEXT NOT NULL DEFAULT 'query',
|
||||
ADD COLUMN page_segment TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN page_title_suffix TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN page_description_suffix TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN publisher_type TEXT NOT NULL DEFAULT 'organization',
|
||||
ADD COLUMN publisher_name TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN publisher_logo TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN publisher_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN publisher_email TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN publisher_phone TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN publisher_same_as TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN local_type TEXT NOT NULL DEFAULT 'LocalBusiness',
|
||||
ADD COLUMN local_price_range TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_street TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_postal_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_city TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_region TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_country TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_latitude TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_longitude TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN local_hours JSONB NULL`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_seo_settings
|
||||
DROP COLUMN IF EXISTS breadcrumb_home, DROP COLUMN IF EXISTS page_form,
|
||||
DROP COLUMN IF EXISTS page_segment, DROP COLUMN IF EXISTS page_title_suffix,
|
||||
DROP COLUMN IF EXISTS page_description_suffix, DROP COLUMN IF EXISTS publisher_type,
|
||||
DROP COLUMN IF EXISTS publisher_name, DROP COLUMN IF EXISTS publisher_logo,
|
||||
DROP COLUMN IF EXISTS publisher_url, DROP COLUMN IF EXISTS publisher_email,
|
||||
DROP COLUMN IF EXISTS publisher_phone, DROP COLUMN IF EXISTS publisher_same_as,
|
||||
DROP COLUMN IF EXISTS local_enabled, DROP COLUMN IF EXISTS local_type,
|
||||
DROP COLUMN IF EXISTS local_price_range, DROP COLUMN IF EXISTS local_street,
|
||||
DROP COLUMN IF EXISTS local_postal_code, DROP COLUMN IF EXISTS local_city,
|
||||
DROP COLUMN IF EXISTS local_region, DROP COLUMN IF EXISTS local_country,
|
||||
DROP COLUMN IF EXISTS local_latitude, DROP COLUMN IF EXISTS local_longitude,
|
||||
DROP COLUMN IF EXISTS local_hours`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(addPublisherPagination...)
|
||||
}
|
||||
Reference in New Issue
Block a user