Files
sm-seo-plugin/classes/schema/graph.go
Jakub Zych ea2ce24954 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
2026-11-04 14:06:57 +01:00

308 lines
8.3 KiB
Go

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