- Publisher and Local business tabs with the C20 refusals (sameAs lines, person as local business, address, times, country, coordinates) - the publisher node from the settings, as an Organization, a Person or the chosen local business type with address, geo and opening hours - P8 publisher preview with Google's required and recommended findings (classes/schema Validate) - the home crumb name on the General tab; every new key in the settings API and seo_update_settings
301 lines
8.0 KiB
Go
301 lines
8.0 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|