- 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
341 lines
9.5 KiB
Go
341 lines
9.5 KiB
Go
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
|
|
}
|