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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user