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 }