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