diff --git a/classes/alternates.go b/classes/alternates.go new file mode 100644 index 0000000..0dcbe27 --- /dev/null +++ b/classes/alternates.go @@ -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 _. +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 +} diff --git a/classes/breadcrumbs.go b/classes/breadcrumbs.go new file mode 100644 index 0000000..5560e79 --- /dev/null +++ b/classes/breadcrumbs.go @@ -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: :. +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: / 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}) +} diff --git a/classes/resolver.go b/classes/resolver.go index f660476..9dfa9af 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -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 (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 } diff --git a/classes/schema/graph.go b/classes/schema/graph.go new file mode 100644 index 0000000..221abcc --- /dev/null +++ b/classes/schema/graph.go @@ -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) +} diff --git a/classes/schema/nodes.go b/classes/schema/nodes.go new file mode 100644 index 0000000..fb7d856 --- /dev/null +++ b/classes/schema/nodes.go @@ -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 +} diff --git a/classes/structured.go b/classes/structured.go new file mode 100644 index 0000000..168c0ff --- /dev/null +++ b/classes/structured.go @@ -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 +} diff --git a/config/config.yaml b/config/config.yaml index 6846760..9da2b9f 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -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}" diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 80c39a8..7ae9e79 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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 diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index e667c58..797a136 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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 diff --git a/models/settings.go b/models/settings.go index 4234832..feebbc8 100644 --- a/models/settings.go +++ b/models/settings.go @@ -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 + // (//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() } diff --git a/structured_data_test.go b/structured_data_test.go new file mode 100644 index 0000000..5e16357 --- /dev/null +++ b/structured_data_test.go @@ -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, " > ") +} diff --git a/updates/202610280004_add_golem15_seo_publisher_pagination.go b/updates/202610280004_add_golem15_seo_publisher_pagination.go new file mode 100644 index 0000000..576dae7 --- /dev/null +++ b/updates/202610280004_add_golem15_seo_publisher_pagination.go @@ -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...) +}