diff --git a/classes/schema/graph.go b/classes/schema/graph.go index 221abcc..e39a1bc 100644 --- a/classes/schema/graph.go +++ b/classes/schema/graph.go @@ -53,13 +53,6 @@ const ( 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"` diff --git a/classes/schema/validate.go b/classes/schema/validate.go new file mode 100644 index 0000000..fa866b2 --- /dev/null +++ b/classes/schema/validate.go @@ -0,0 +1,153 @@ +package schema + +import "strings" + +// The levels of a finding. +const ( + LevelRequired = "required" + LevelRecommended = "recommended" +) + +// Finding is one missing property of a node. +type Finding struct { + Level string `json:"level"` + Property string `json:"property"` +} + +// Report is a node with its findings. Rules is false for a type Google has +// no rich-result rules for (FAQPage, CollectionPage, WebPage, Service …; +// RESEARCH Pitfall 11): it is checked as valid schema.org only. +type Report struct { + ID string `json:"id"` + Type string `json:"type"` + Findings []Finding `json:"findings"` + Rules bool `json:"rules"` +} + +// rule is the required and recommended properties of a type (Google's +// structured data documentation, cited in 33-RESEARCH.md). +type rule struct { + required []string + recommended []string +} + +var articleRule = rule{recommended: []string{"author", "datePublished", "dateModified", "headline", "image"}} + +var localRule = rule{required: []string{"name", "address"}, recommended: []string{"telephone", "url", "geo", "openingHoursSpecification", "priceRange", "image"}} + +// rules lists the types with Google rules; every other type has none. +var rules = map[string]rule{ + TypeArticle: articleRule, + TypeBlogPosting: articleRule, + TypeNewsArticle: articleRule, + TypeBreadcrumbList: {required: []string{"itemListElement"}}, + TypeVideoObject: {required: []string{"name", "thumbnailUrl", "uploadDate"}, recommended: []string{"description", "embedUrl"}}, + TypeOrganization: {recommended: []string{"name", "url", "logo"}}, + TypePerson: {required: []string{"name"}, recommended: []string{"url", "image"}}, + TypeWebSite: {required: []string{"name", "url"}}, + TypeProfilePage: {required: []string{"mainEntity"}}, +} + +// localTypes are the LocalBusiness types (UI-SPEC C20), checked with the +// LocalBusiness rules. +var localTypes = map[string]bool{ + "LocalBusiness": true, "ProfessionalService": true, "Store": true, + "FoodEstablishment": true, "HealthAndBeautyBusiness": true, + "MedicalBusiness": true, "LegalService": true, + "HomeAndConstructionBusiness": true, "AutomotiveBusiness": true, + "FinancialService": true, +} + +// RulesFor returns the rule of typ and whether Google has rules for it. +func RulesFor(typ string) (required, recommended []string, ok bool) { + if localTypes[typ] { + return localRule.required, localRule.recommended, true + } + r, ok := rules[typ] + return r.required, r.recommended, ok +} + +// NodeType is a node's first @type, "" when it has none. +func NodeType(n map[string]any) string { + switch t := n["@type"].(type) { + case string: + return t + case []any: + for _, v := range t { + if s, ok := v.(string); ok { + return s + } + } + case []string: + if len(t) > 0 { + return t[0] + } + } + return "" +} + +// present reports whether a node property carries a value. +func present(v any) bool { + switch x := v.(type) { + case nil: + return false + case string: + return strings.TrimSpace(x) != "" + case []any: + return len(x) > 0 + case map[string]any: + return len(x) > 0 + } + return true +} + +// ValidateNode checks one node against its type's rules. +func ValidateNode(n map[string]any) Report { + typ := NodeType(n) + id, _ := n["@id"].(string) + rep := Report{ID: id, Type: typ, Findings: []Finding{}} + required, recommended, ok := RulesFor(typ) + rep.Rules = ok + for _, p := range required { + if !present(n[p]) { + rep.Findings = append(rep.Findings, Finding{Level: LevelRequired, Property: p}) + } + } + if typ == TypeBreadcrumbList { + if items, _ := n["itemListElement"].([]any); len(items) > 0 && len(items) < 2 { + rep.Findings = append(rep.Findings, Finding{Level: LevelRequired, Property: "itemListElement"}) + } + } + for _, p := range recommended { + if !present(n[p]) { + rep.Findings = append(rep.Findings, Finding{Level: LevelRecommended, Property: p}) + } + } + return rep +} + +// Nodes returns the node objects of a graph. +func Nodes(graph map[string]any) []map[string]any { + var out []map[string]any + switch list := graph["@graph"].(type) { + case []any: + for _, raw := range list { + if n, ok := raw.(map[string]any); ok { + out = append(out, n) + } + } + case []map[string]any: + out = append(out, list...) + } + return out +} + +// Validate checks every node of a graph (D-46): the admin preview, the +// MCP tool and the publisher preview share it. +func Validate(graph map[string]any) []Report { + out := []Report{} + for _, n := range Nodes(graph) { + out = append(out, ValidateNode(n)) + } + return out +} diff --git a/classes/settings_write.go b/classes/settings_write.go index c56761e..5a530d2 100644 --- a/classes/settings_write.go +++ b/classes/settings_write.go @@ -39,7 +39,51 @@ var SettingsColumns = []string{ "noindex_paths", "search_paths", "url_host", "url_slash", "url_lowercase", "links_new_tab", "nofollow_domains", "sponsored_domains", "verify_google", "verify_bing", "verify_yandex", - "verify_other", "verify_files", "csv_delimiter", + "verify_other", "verify_files", "csv_delimiter", "breadcrumb_home", + "publisher_type", "publisher_name", "publisher_logo", "publisher_url", + "publisher_email", "publisher_phone", "publisher_same_as", + "local_enabled", "local_type", "local_price_range", "local_street", + "local_postal_code", "local_city", "local_region", "local_country", + "local_latitude", "local_longitude", "local_hours", +} + +// settingsText maps the plain text settings columns to their fields. +func settingsText(st *models.Settings) map[string]*string { + return map[string]*string{ + "site_name": &st.SiteName, + "twitter_site": &st.TwitterSite, + "title_template": &st.TitleTemplate, + "home_template": &st.HomeTemplate, + "default_description": &st.DefaultDescription, + "default_og_image": &st.DefaultOGImage, + "noindex_paths": &st.NoindexPaths, + "search_paths": &st.SearchPaths, + "url_host": &st.URLHost, + "url_slash": &st.URLSlash, + "verify_google": &st.VerifyGoogle, + "verify_bing": &st.VerifyBing, + "verify_yandex": &st.VerifyYandex, + "csv_delimiter": &st.CSVDelimiter, + "nofollow_domains": &st.NofollowDomains, + "sponsored_domains": &st.SponsoredDomains, + "breadcrumb_home": &st.BreadcrumbHome, + "publisher_type": &st.PublisherType, + "publisher_name": &st.PublisherName, + "publisher_logo": &st.PublisherLogo, + "publisher_url": &st.PublisherURL, + "publisher_email": &st.PublisherEmail, + "publisher_phone": &st.PublisherPhone, + "publisher_same_as": &st.PublisherSameAs, + "local_type": &st.LocalType, + "local_price_range": &st.LocalPriceRange, + "local_street": &st.LocalStreet, + "local_postal_code": &st.LocalPostalCode, + "local_city": &st.LocalCity, + "local_region": &st.LocalRegion, + "local_country": &st.LocalCountry, + "local_latitude": &st.LocalLatitude, + "local_longitude": &st.LocalLongitude, + } } // SettingsValues are the settings row's values keyed by column, for @@ -67,6 +111,25 @@ func SettingsValues(st models.Settings) map[string]any { "verify_other": plainValue(st.OtherMeta()), "verify_files": plainValue(st.Files()), "csv_delimiter": st.CSVDelimiter, + "breadcrumb_home": st.BreadcrumbHome, + "publisher_type": st.PublisherType, + "publisher_name": st.PublisherName, + "publisher_logo": st.PublisherLogo, + "publisher_url": st.PublisherURL, + "publisher_email": st.PublisherEmail, + "publisher_phone": st.PublisherPhone, + "publisher_same_as": st.PublisherSameAs, + "local_enabled": st.LocalEnabled, + "local_type": st.LocalType, + "local_price_range": st.LocalPriceRange, + "local_street": st.LocalStreet, + "local_postal_code": st.LocalPostalCode, + "local_city": st.LocalCity, + "local_region": st.LocalRegion, + "local_country": st.LocalCountry, + "local_latitude": st.LocalLatitude, + "local_longitude": st.LocalLongitude, + "local_hours": plainValue(st.Hours()), } } @@ -93,52 +156,34 @@ func SetSettingsValue(st *models.Settings, column string, v any) error { } return json.Unmarshal(raw, dst) } + if field, ok := settingsText(st)[column]; ok { + var err error + *field, err = text() + return err + } var err error switch column { - case "site_name": - st.SiteName, err = text() - case "twitter_site": - st.TwitterSite, err = text() - case "title_template": - st.TitleTemplate, err = text() - case "home_template": - st.HomeTemplate, err = text() - case "default_description": - st.DefaultDescription, err = text() - case "default_og_image": - st.DefaultOGImage, err = text() - case "noindex_paths": - st.NoindexPaths, err = text() - case "search_paths": - st.SearchPaths, err = text() - case "url_host": - st.URLHost, err = text() - case "url_slash": - st.URLSlash, err = text() - case "verify_google": - st.VerifyGoogle, err = text() - case "verify_bing": - st.VerifyBing, err = text() - case "verify_yandex": - st.VerifyYandex, err = text() - case "csv_delimiter": - st.CSVDelimiter, err = text() - case "nofollow_domains": - st.NofollowDomains, err = text() - case "sponsored_domains": - st.SponsoredDomains, err = text() case "url_lowercase": b, ok := v.(bool) if !ok { return fmt.Errorf("golem15.seo: settings %s is not a bool", column) } st.URLLowercase = b - case "links_new_tab": + case "links_new_tab", "local_enabled": b, ok := v.(bool) if !ok { return fmt.Errorf("golem15.seo: settings %s is not a bool", column) } - st.LinksNewTab = b + if column == "links_new_tab" { + st.LinksNewTab = b + } else { + st.LocalEnabled = b + } + case "local_hours": + var out []models.OpeningHours + if err = list(&out); err == nil { + st.LocalHours = lagoon.Jsonable[[]models.OpeningHours]{Data: out, Valid: true} + } case "query_params": var out []models.QueryParam if err = list(&out); err == nil { @@ -216,8 +261,14 @@ func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrit oneOf("url_host", row.URLHost, models.HostKeep, models.HostWWW, models.HostBare) oneOf("url_slash", row.URLSlash, models.SlashKeep, models.SlashAdd, models.SlashDrop) oneOf("csv_delimiter", row.CSVDelimiter, "", ",", ";") - if _, sent := in.Values["default_og_image"]; sent && !ValidImageURL(row.DefaultOGImage) { - problems["default_og_image"] = append(problems["default_og_image"], models.Problem{Key: errImageURL}) + for _, column := range []string{"default_og_image", "publisher_logo"} { + value := row.DefaultOGImage + if column == "publisher_logo" { + value = row.PublisherLogo + } + if _, sent := in.Values[column]; sent && !ValidImageURL(value) { + problems[column] = append(problems[column], models.Problem{Key: errImageURL}) + } } for field, list := range row.Problems() { problems[field] = append(problems[field], list...) diff --git a/classes/structured.go b/classes/structured.go index 168c0ff..96603d0 100644 --- a/classes/structured.go +++ b/classes/structured.go @@ -2,6 +2,7 @@ package classes import ( "context" + "encoding/json" "strconv" "strings" @@ -220,3 +221,50 @@ func (s *Service) structuredData(ctx context.Context, db *gorm.DB, settings mode res.JSONLD = graph return nil } + +// PublisherPreview is the view model of the settings' P8 partial: the +// generated publisher node as indented JSON and its findings as lines, or +// the all-good line. +type PublisherPreview struct { + JSON string + Findings []string + AllGood string +} + +// FindingText renders a finding with the graph copy of the request locale. +func (s *Service) FindingText(ctx context.Context, typ string, f schema.Finding) string { + key := "golem15.seo::lang.graph.recommended" + if f.Level == schema.LevelRequired { + key = "golem15.seo::lang.graph.required" + } + return s.PhraseCtx(ctx, key, map[string]string{"property": f.Property, "type": typ}) +} + +// PublisherPreview builds P8 from the stored settings in the default +// locale (UI-SPEC C20). +func (s *Service) PublisherPreview(ctx context.Context) (PublisherPreview, error) { + db, err := s.DB(ctx) + if err != nil { + return PublisherPreview{}, err + } + settings, err := LoadSettings(db) + if err != nil { + return PublisherPreview{}, err + } + locale := s.DefaultLocale(ctx, db) + node := schema.PublisherNode(s.SiteURL(), s.PublisherOf(ctx, db, settings, locale), s.SiteName(ctx, settings, locale)) + out := PublisherPreview{AllGood: s.PhraseCtx(ctx, "golem15.seo::lang.graph.all_good", nil)} + if node == nil { + return out, nil + } + raw, err := json.MarshalIndent(node, "", " ") + if err != nil { + return PublisherPreview{}, err + } + out.JSON = string(raw) + rep := schema.ValidateNode(node) + for _, f := range rep.Findings { + out.Findings = append(out.Findings, s.FindingText(ctx, rep.Type, f)) + } + return out, nil +} diff --git a/controllers/api/settings.go b/controllers/api/settings.go index 35cf545..b88cb3e 100644 --- a/controllers/api/settings.go +++ b/controllers/api/settings.go @@ -34,6 +34,25 @@ type UpdateSettingsInput struct { VerifyOther *[]models.VerifyMeta `json:"verify_other,omitempty" jsonschema:"other verification meta tags (name, content)"` VerifyFiles *[]models.VerifyFile `json:"verify_files,omitempty" jsonschema:"verification files served from the site root (filename, body)"` CSVDelimiter *string `json:"csv_delimiter,omitempty" jsonschema:"the import and export delimiter: , or ;"` + BreadcrumbHome *string `json:"breadcrumb_home,omitempty" jsonschema:"the home page name in breadcrumb trails and BreadcrumbList (default locale); empty: Home / Strona główna"` + PublisherType *string `json:"publisher_type,omitempty" jsonschema:"who publishes the site in the JSON-LD of every page: organization or person"` + PublisherName *string `json:"publisher_name,omitempty" jsonschema:"the publisher name (default locale); empty: the site name"` + PublisherLogo *string `json:"publisher_logo,omitempty" jsonschema:"the organisation logo (at least 112x112 px) or the person's photo: a URL or a site path"` + PublisherURL *string `json:"publisher_url,omitempty" jsonschema:"the publisher website, an https URL; empty: the site address"` + PublisherEmail *string `json:"publisher_email,omitempty" jsonschema:"the publisher contact email"` + PublisherPhone *string `json:"publisher_phone,omitempty" jsonschema:"the publisher phone with the country code, e.g. +48 600 000 000"` + PublisherSameAs *string `json:"publisher_same_as,omitempty" jsonschema:"profiles elsewhere (sameAs), one https URL per line"` + LocalEnabled *bool `json:"local_enabled,omitempty" jsonschema:"publish the organisation as a local business (not for a person)"` + LocalType *string `json:"local_type,omitempty" jsonschema:"the local business type: LocalBusiness, ProfessionalService, Store, FoodEstablishment, HealthAndBeautyBusiness, MedicalBusiness, LegalService, HomeAndConstructionBusiness, AutomotiveBusiness or FinancialService"` + LocalPriceRange *string `json:"local_price_range,omitempty" jsonschema:"the price range, e.g. 50–200 zł or $$"` + LocalStreet *string `json:"local_street,omitempty" jsonschema:"street and number (required for a local business)"` + LocalPostalCode *string `json:"local_postal_code,omitempty" jsonschema:"postal code (required for a local business)"` + LocalCity *string `json:"local_city,omitempty" jsonschema:"city (required for a local business)"` + LocalRegion *string `json:"local_region,omitempty" jsonschema:"region"` + LocalCountry *string `json:"local_country,omitempty" jsonschema:"two-letter country code, e.g. PL"` + LocalLatitude *string `json:"local_latitude,omitempty" jsonschema:"latitude in decimal degrees (-90 to 90)"` + LocalLongitude *string `json:"local_longitude,omitempty" jsonschema:"longitude in decimal degrees (-180 to 180)"` + LocalHours *[]models.OpeningHours `json:"local_hours,omitempty" jsonschema:"opening hours rows: day (mon … sun), opens and closes in 24-hour HH:MM; two rows split a day, a day without rows is closed"` Types map[string]classes.TypeSettingWrite `json:"types,omitempty" jsonschema:"per content type key: inject (show the SEO tab), title_template, description_template, robots"` } diff --git a/controllers/settings.go b/controllers/settings.go index 018ac31..b875426 100644 --- a/controllers/settings.go +++ b/controllers/settings.go @@ -107,8 +107,8 @@ type stagingView struct { } // PartialData serves the staging callout, shown outside production where -// every page is noindex whatever the settings say (D-22), and the P3 -// placeholder table. +// every page is noindex whatever the settings say (D-22), the P3 +// placeholder table and the P8 publisher preview. func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) { switch name { case "staging": @@ -119,6 +119,12 @@ func (c settingsAdminController) PartialData(ctx context.Context, name string, _ return nil, err } return svc.Placeholders(ctx), nil + case "publisher_preview": + svc, err := classes.NewService(appOf(c.app)) + if err != nil { + return nil, err + } + return svc.PublisherPreview(ctx) } return nil, nil } diff --git a/controllers/settings/_publisher_preview.htm b/controllers/settings/_publisher_preview.htm new file mode 100644 index 0000000..0c74225 --- /dev/null +++ b/controllers/settings/_publisher_preview.htm @@ -0,0 +1,14 @@ +{{- if .Data.JSON -}} +
+
{{ .Data.JSON }}
+
+{{- end -}} +{{- range .Data.Findings -}} +
+

{{ . }}

+
+{{- else -}} +
+

{{ .Data.AllGood }}

+
+{{- end -}} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 7ae9e79..ad6bbdb 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -149,6 +149,73 @@ settings: verify_filename_taken: Another verification file already uses this name. template_unknown: Unknown placeholder “:placeholder”. Use one from the table below the templates. breadcrumb_home_default: "Home" + tab_publisher: "Publisher" + tab_local: "Local business" + breadcrumb_home: "Home page name in breadcrumbs" + breadcrumb_home_comment: "The first item of every breadcrumb trail, also in the BreadcrumbList structured data." + publisher_type: "Publisher type" + publisher_org: "Organisation" + publisher_person: "Person (personal site)" + publisher_type_comment: "Who publishes the site, in the structured data of every page." + publisher_name: "Name" + publisher_name_comment: "Empty: the site name." + publisher_logo: "Logo or photo" + publisher_logo_comment: "Organisation: the logo, at least 112 × 112 px. Person: a photo." + publisher_url: "Website" + publisher_url_comment: "Empty: the site address." + publisher_same_as: "Profiles elsewhere (sameAs)" + publisher_same_as_comment: "One address per line: social profiles, Wikipedia, the company register entry." + same_as_invalid: "Line :line: use a full address starting with https://." + url_invalid: "Use a full address starting with https://." + email_invalid: "Enter an email address such as office@example.com." + publisher_email: "Contact email" + publisher_phone: "Phone" + publisher_phone_comment: "With the country code, e.g. +48 600 000 000." + publisher_preview: "Generated publisher data" + local_enabled: "Publish as a local business" + local_enabled_comment: "For a business with an address that customers visit, or with a service area. Not available for a person." + local_type: "Kind of business" + local_type_local_business: "Local business (general)" + local_type_professional_service: "Professional services" + local_type_store: "Shop" + local_type_food_establishment: "Restaurant or café" + local_type_health_and_beauty_business: "Health and beauty" + local_type_medical_business: "Medical practice" + local_type_legal_service: "Legal services" + local_type_home_and_construction_business: "Home and construction" + local_type_automotive_business: "Automotive" + local_type_financial_service: "Financial services" + local_price_range: "Price range" + local_price_range_comment: "e.g. 50–200 zł or $$." + local_street: "Street and number" + local_postal_code: "Postal code" + local_city: "City" + local_region: "Region" + local_country: "Country code" + local_country_comment: "Two letters, e.g. PL." + local_latitude: "Latitude" + local_longitude: "Longitude" + local_coordinates_comment: "Decimal degrees, e.g. 52.2297." + local_hours: "Opening hours" + local_hours_add: "Add opening hours" + local_hours_comment: "Add two rows for one day to split it, e.g. 9:00–13:00 and 14:00–18:00. A day without rows is closed." + hours_day: "Day" + hours_opens: "Opens" + hours_closes: "Closes" + day_mon: "Monday" + day_tue: "Tuesday" + day_wed: "Wednesday" + day_thu: "Thursday" + day_fri: "Friday" + day_sat: "Saturday" + day_sun: "Sunday" + hours_time_comment: "24-hour time, e.g. 09:00." + local_person: "A person cannot be a local business. Change the publisher type to Organisation, or turn this off." + local_address_required: "A local business needs a street, a postal code and a city." + time_invalid: "Use 24-hour time, e.g. 09:00." + hours_order: "Closing must come after opening." + country_invalid: "Use a two-letter country code, e.g. PL." + coordinate_invalid: "Enter decimal degrees: latitude from -90 to 90, longitude from -180 to 180." rules: new: New URL rule list_title: URL rules @@ -410,3 +477,9 @@ pages: one: "Delete :count page from the registry? Its SEO tab is deleted with it." other: "Delete :count pages from the registry? Their SEO tabs are deleted with them." home_title: Home +graph: + action: "Check structured data" + required: "Missing required property “:property” (:type)" + recommended: "Missing recommended property “:property” (:type)" + no_rules: ":type has no rich-result rules at Google; it is checked as valid schema.org only." + all_good: "No problems: every node has its required and recommended properties." diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 797a136..e2223e4 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -151,6 +151,73 @@ settings: verify_filename_taken: Ten plik weryfikacyjny już istnieje. template_unknown: Nieznany znacznik „:placeholder”. Użyj jednego z tabeli pod szablonami. breadcrumb_home_default: "Strona główna" + tab_publisher: "Wydawca" + tab_local: "Firma lokalna" + breadcrumb_home: "Nazwa strony głównej w ścieżce nawigacji" + breadcrumb_home_comment: "Pierwszy element każdej ścieżki nawigacji, także w danych strukturalnych BreadcrumbList." + publisher_type: "Typ wydawcy" + publisher_org: "Organizacja" + publisher_person: "Osoba (strona prywatna)" + publisher_type_comment: "Kto wydaje witrynę, w danych strukturalnych każdej strony." + publisher_name: "Nazwa" + publisher_name_comment: "Puste: nazwa witryny." + publisher_logo: "Logo lub zdjęcie" + publisher_logo_comment: "Organizacja: logo, co najmniej 112 × 112 px. Osoba: zdjęcie." + publisher_url: "Strona internetowa" + publisher_url_comment: "Puste: adres witryny." + publisher_same_as: "Profile w innych serwisach (sameAs)" + publisher_same_as_comment: "Jeden adres w wierszu: profile społecznościowe, Wikipedia, wpis w KRS lub CEIDG." + same_as_invalid: "Wiersz :line: wpisz pełny adres zaczynający się od https://." + url_invalid: "Wpisz pełny adres zaczynający się od https://." + email_invalid: "Wpisz adres e-mail, np. biuro@example.com." + publisher_email: "E-mail kontaktowy" + publisher_phone: "Telefon" + publisher_phone_comment: "Z numerem kierunkowym kraju, np. +48 600 000 000." + publisher_preview: "Wygenerowane dane wydawcy" + local_enabled: "Publikuj jako firmę lokalną" + local_enabled_comment: "Dla firmy z adresem, który odwiedzają klienci, lub z obszarem obsługi. Niedostępne dla osoby." + local_type: "Rodzaj działalności" + local_type_local_business: "Firma lokalna (ogólnie)" + local_type_professional_service: "Usługi profesjonalne" + local_type_store: "Sklep" + local_type_food_establishment: "Restauracja lub kawiarnia" + local_type_health_and_beauty_business: "Zdrowie i uroda" + local_type_medical_business: "Praktyka medyczna" + local_type_legal_service: "Usługi prawne" + local_type_home_and_construction_business: "Dom i budownictwo" + local_type_automotive_business: "Motoryzacja" + local_type_financial_service: "Usługi finansowe" + local_price_range: "Przedział cen" + local_price_range_comment: "np. 50–200 zł lub $$." + local_street: "Ulica i numer" + local_postal_code: "Kod pocztowy" + local_city: "Miejscowość" + local_region: "Województwo lub region" + local_country: "Kod kraju" + local_country_comment: "Dwie litery, np. PL." + local_latitude: "Szerokość geograficzna" + local_longitude: "Długość geograficzna" + local_coordinates_comment: "Stopnie dziesiętne, np. 52.2297." + local_hours: "Godziny otwarcia" + local_hours_add: "Dodaj godziny otwarcia" + local_hours_comment: "Dodaj dwa wiersze dla jednego dnia, aby go podzielić, np. 9:00–13:00 i 14:00–18:00. Dzień bez wierszy jest nieczynny." + hours_day: "Dzień" + hours_opens: "Otwarcie" + hours_closes: "Zamknięcie" + day_mon: "Poniedziałek" + day_tue: "Wtorek" + day_wed: "Środa" + day_thu: "Czwartek" + day_fri: "Piątek" + day_sat: "Sobota" + day_sun: "Niedziela" + hours_time_comment: "Format 24-godzinny, np. 09:00." + local_person: "Osoba nie może być firmą lokalną. Zmień typ wydawcy na Organizacja lub wyłącz tę opcję." + local_address_required: "Firma lokalna wymaga ulicy, kodu pocztowego i miejscowości." + time_invalid: "Użyj formatu 24-godzinnego, np. 09:00." + hours_order: "Godzina zamknięcia musi być późniejsza niż otwarcia." + country_invalid: "Użyj dwuliterowego kodu kraju, np. PL." + coordinate_invalid: "Wpisz stopnie dziesiętne: szerokość od -90 do 90, długość od -180 do 180." rules: new: Nowa reguła adresu list_title: Reguły adresów @@ -424,3 +491,9 @@ pages: many: "Usunąć :count stron z rejestru? Ich zakładki SEO zostaną usunięte." other: "Usunąć :count strony z rejestru? Ich zakładki SEO zostaną usunięte." home_title: Strona główna +graph: + action: "Sprawdź dane strukturalne" + required: "Brak wymaganej właściwości „:property” (:type)" + recommended: "Brak zalecanej właściwości „:property” (:type)" + no_rules: ":type nie ma reguł wyników z elementami rozszerzonymi w Google; sprawdzana jest tylko poprawność schema.org." + all_good: "Brak problemów: każdy węzeł ma wymagane i zalecane właściwości." diff --git a/models/settings.go b/models/settings.go index feebbc8..d358edf 100644 --- a/models/settings.go +++ b/models/settings.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "net/url" "regexp" "strconv" "strings" @@ -40,8 +41,28 @@ const ( errVerifyFilenameTaken = "golem15.seo::lang.settings.verify_filename_taken" errTemplateUnknown = "golem15.seo::lang.settings.template_unknown" errDomainInvalid = "golem15.seo::lang.settings.domain_invalid" + errValueInvalid = "golem15.seo::lang.api.value_invalid" + errSameAsInvalid = "golem15.seo::lang.settings.same_as_invalid" + errURLInvalid = "golem15.seo::lang.settings.url_invalid" + errEmailInvalid = "golem15.seo::lang.settings.email_invalid" + errLocalPerson = "golem15.seo::lang.settings.local_person" + errLocalAddress = "golem15.seo::lang.settings.local_address_required" + errTimeInvalid = "golem15.seo::lang.settings.time_invalid" + errHoursOrder = "golem15.seo::lang.settings.hours_order" + errCountryInvalid = "golem15.seo::lang.settings.country_invalid" + errCoordinateInvalid = "golem15.seo::lang.settings.coordinate_invalid" ) +// LocalTypes are the LocalBusiness types the settings offer (UI-SPEC C20). +var LocalTypes = []string{ + "LocalBusiness", "ProfessionalService", "Store", "FoodEstablishment", + "HealthAndBeautyBusiness", "MedicalBusiness", "LegalService", + "HomeAndConstructionBusiness", "AutomotiveBusiness", "FinancialService", +} + +// Days are the day codes of the opening hours, Monday first. +var Days = []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"} + // QueryParam is one row of the D-07 query-parameter list: a name (a // trailing * matches a prefix), its kind, and an optional path pattern the // row is limited to. @@ -174,7 +195,12 @@ func (Settings) Fillable() []string { "noindex_paths", "search_paths", "url_host", "url_slash", "url_lowercase", "verify_google", "verify_bing", "verify_yandex", "verify_other", "verify_files", "links_new_tab", "nofollow_domains", - "sponsored_domains", + "sponsored_domains", "breadcrumb_home", "publisher_type", + "publisher_name", "publisher_logo", "publisher_url", "publisher_email", + "publisher_phone", "publisher_same_as", "local_enabled", "local_type", + "local_price_range", "local_street", "local_postal_code", "local_city", + "local_region", "local_country", "local_latitude", "local_longitude", + "local_hours", } } @@ -278,6 +304,108 @@ func (s Settings) Problems() map[string][]Problem { out[field] = p } } + for field, list := range s.PublisherProblems() { + out[field] = append(out[field], list...) + } + return out +} + +// timePattern is a 24-hour HH:MM time. +var timePattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) + +// countryPattern is a two-letter country code. +var countryPattern = regexp.MustCompile(`^[A-Za-z]{2}$`) + +// HTTPSURL reports whether s is an absolute https URL with a host. +func HTTPSURL(s string) bool { + u, err := url.Parse(strings.TrimSpace(s)) + return err == nil && u.Scheme == "https" && u.Host != "" && !strings.ContainsAny(s, " \t\n<>\"") +} + +// oneOfList reports whether v is one of list. +func oneOfList(v string, list []string) bool { + for _, item := range list { + if v == item { + return true + } + } + return false +} + +// PublisherProblems lists the refusals of the Publisher and Local +// business tabs (UI-SPEC C20, D-45, T-33-25): the publisher type, https +// sameAs lines (with the line number), the website, the email, a person +// as a local business, the business's address, country, coordinates and +// opening hours (local_hours..opens / .closes). +func (s Settings) PublisherProblems() map[string][]Problem { + out := map[string][]Problem{} + add := func(field string, p Problem) { out[field] = append(out[field], p) } + if s.PublisherType != "" && !oneOfList(s.PublisherType, []string{PublisherOrganization, PublisherPerson}) { + add("publisher_type", Problem{Key: errValueInvalid}) + } + n := 0 + for _, line := range strings.Split(strings.ReplaceAll(s.PublisherSameAs, "\r\n", "\n"), "\n") { + n++ + if v := strings.TrimSpace(line); v != "" && !HTTPSURL(v) { + add("publisher_same_as", Problem{Key: errSameAsInvalid, Params: map[string]string{"line": strconv.Itoa(n)}}) + break + } + } + if v := strings.TrimSpace(s.PublisherURL); v != "" && !HTTPSURL(v) { + add("publisher_url", Problem{Key: errURLInvalid}) + } + if v := strings.TrimSpace(s.PublisherEmail); v != "" { + local, domain, ok := strings.Cut(v, "@") + if !ok || local == "" || !strings.Contains(domain, ".") || strings.ContainsAny(v, " <>\"") { + add("publisher_email", Problem{Key: errEmailInvalid}) + } + } + if !s.LocalEnabled { + return out + } + if s.PublisherType == PublisherPerson { + add("local_enabled", Problem{Key: errLocalPerson}) + } + if s.LocalType != "" && !oneOfList(s.LocalType, LocalTypes) { + add("local_type", Problem{Key: errValueInvalid}) + } + if strings.TrimSpace(s.LocalStreet) == "" || strings.TrimSpace(s.LocalPostalCode) == "" || strings.TrimSpace(s.LocalCity) == "" { + for field, v := range map[string]string{"local_street": s.LocalStreet, "local_postal_code": s.LocalPostalCode, "local_city": s.LocalCity} { + if strings.TrimSpace(v) == "" { + add(field, Problem{Key: errLocalAddress}) + } + } + } + if v := strings.TrimSpace(s.LocalCountry); v != "" && !countryPattern.MatchString(v) { + add("local_country", Problem{Key: errCountryInvalid}) + } + coordinate := func(field, v string, limit float64) { + if v = strings.TrimSpace(v); v == "" { + return + } + f, err := strconv.ParseFloat(strings.ReplaceAll(v, ",", "."), 64) + if err != nil || f < -limit || f > limit { + add(field, Problem{Key: errCoordinateInvalid}) + } + } + coordinate("local_latitude", s.LocalLatitude, 90) + coordinate("local_longitude", s.LocalLongitude, 180) + for i, h := range s.Hours() { + base := fmt.Sprintf("local_hours.%d.", i) + if !oneOfList(h.Day, Days) { + add(base+"day", Problem{Key: errValueInvalid}) + } + okOpens, okCloses := timePattern.MatchString(h.Opens), timePattern.MatchString(h.Closes) + if !okOpens { + add(base+"opens", Problem{Key: errTimeInvalid}) + } + if !okCloses { + add(base+"closes", Problem{Key: errTimeInvalid}) + } + if okOpens && okCloses && h.Closes <= h.Opens { + add(base+"closes", Problem{Key: errHoursOrder}) + } + } return out } diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 356d430..80e708a 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -2,9 +2,9 @@ # Form Field Definitions # =================================== # -# SEO settings (UI-SPEC C11): General, one tab per content type (added by -# the plugin's form extension after General), Indexing, Addresses, Links -# (UI-SPEC C20) and Verification. +# SEO settings (UI-SPEC C11, C20): General, one tab per content type (added +# by the plugin's form extension after General), Indexing, Addresses, +# Links, Publisher, Local business and Verification. fields: _staging: @@ -27,6 +27,13 @@ fields: span: right tab: golem15.seo::lang.settings.tab_general + breadcrumb_home: + label: golem15.seo::lang.settings.breadcrumb_home + comment: golem15.seo::lang.settings.breadcrumb_home_comment + type: mltext + span: right + tab: golem15.seo::lang.settings.tab_general + title_template: label: golem15.seo::lang.settings.title_template comment: golem15.seo::lang.settings.title_template_comment @@ -163,6 +170,215 @@ fields: span: right tab: golem15.seo::lang.settings.tab_links + publisher_type: + label: golem15.seo::lang.settings.publisher_type + comment: golem15.seo::lang.settings.publisher_type_comment + type: dropdown + span: left + default: organization + options: + organization: golem15.seo::lang.settings.publisher_org + person: golem15.seo::lang.settings.publisher_person + tab: golem15.seo::lang.settings.tab_publisher + + publisher_name: + label: golem15.seo::lang.settings.publisher_name + comment: golem15.seo::lang.settings.publisher_name_comment + type: mltext + span: right + tab: golem15.seo::lang.settings.tab_publisher + + publisher_logo: + label: golem15.seo::lang.settings.publisher_logo + comment: golem15.seo::lang.settings.publisher_logo_comment + type: seoimage + span: full + tab: golem15.seo::lang.settings.tab_publisher + + publisher_url: + label: golem15.seo::lang.settings.publisher_url + comment: golem15.seo::lang.settings.publisher_url_comment + type: text + span: left + tab: golem15.seo::lang.settings.tab_publisher + + publisher_email: + label: golem15.seo::lang.settings.publisher_email + type: text + span: right + tab: golem15.seo::lang.settings.tab_publisher + + publisher_phone: + label: golem15.seo::lang.settings.publisher_phone + comment: golem15.seo::lang.settings.publisher_phone_comment + type: text + span: left + tab: golem15.seo::lang.settings.tab_publisher + + publisher_same_as: + label: golem15.seo::lang.settings.publisher_same_as + comment: golem15.seo::lang.settings.publisher_same_as_comment + type: textarea + size: small + span: full + tab: golem15.seo::lang.settings.tab_publisher + + _publisher_preview: + label: golem15.seo::lang.settings.publisher_preview + type: partial + path: publisher_preview + span: full + context: update + tab: golem15.seo::lang.settings.tab_publisher + + local_enabled: + label: golem15.seo::lang.settings.local_enabled + comment: golem15.seo::lang.settings.local_enabled_comment + type: switch + span: full + default: false + tab: golem15.seo::lang.settings.tab_local + + local_type: + label: golem15.seo::lang.settings.local_type + type: dropdown + span: left + default: LocalBusiness + options: + LocalBusiness: golem15.seo::lang.settings.local_type_local_business + ProfessionalService: golem15.seo::lang.settings.local_type_professional_service + Store: golem15.seo::lang.settings.local_type_store + FoodEstablishment: golem15.seo::lang.settings.local_type_food_establishment + HealthAndBeautyBusiness: golem15.seo::lang.settings.local_type_health_and_beauty_business + MedicalBusiness: golem15.seo::lang.settings.local_type_medical_business + LegalService: golem15.seo::lang.settings.local_type_legal_service + HomeAndConstructionBusiness: golem15.seo::lang.settings.local_type_home_and_construction_business + AutomotiveBusiness: golem15.seo::lang.settings.local_type_automotive_business + FinancialService: golem15.seo::lang.settings.local_type_financial_service + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_price_range: + label: golem15.seo::lang.settings.local_price_range + comment: golem15.seo::lang.settings.local_price_range_comment + type: text + span: right + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_street: + label: golem15.seo::lang.settings.local_street + type: text + span: full + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_postal_code: + label: golem15.seo::lang.settings.local_postal_code + type: text + span: left + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_city: + label: golem15.seo::lang.settings.local_city + type: text + span: right + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_region: + label: golem15.seo::lang.settings.local_region + type: text + span: left + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_country: + label: golem15.seo::lang.settings.local_country + comment: golem15.seo::lang.settings.local_country_comment + type: text + span: right + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_latitude: + label: golem15.seo::lang.settings.local_latitude + comment: golem15.seo::lang.settings.local_coordinates_comment + type: text + span: left + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_longitude: + label: golem15.seo::lang.settings.local_longitude + comment: golem15.seo::lang.settings.local_coordinates_comment + type: text + span: right + tab: golem15.seo::lang.settings.tab_local + trigger: + action: hide + field: local_enabled + condition: unchecked + + local_hours: + label: golem15.seo::lang.settings.local_hours + comment: golem15.seo::lang.settings.local_hours_comment + type: repeater + prompt: golem15.seo::lang.settings.local_hours_add + titleFrom: day + span: full + tab: golem15.seo::lang.settings.tab_local + form: + fields: + day: + label: golem15.seo::lang.settings.hours_day + type: dropdown + span: left + default: mon + options: + mon: golem15.seo::lang.settings.day_mon + tue: golem15.seo::lang.settings.day_tue + wed: golem15.seo::lang.settings.day_wed + thu: golem15.seo::lang.settings.day_thu + fri: golem15.seo::lang.settings.day_fri + sat: golem15.seo::lang.settings.day_sat + sun: golem15.seo::lang.settings.day_sun + opens: + label: golem15.seo::lang.settings.hours_opens + comment: golem15.seo::lang.settings.hours_time_comment + type: text + span: left + closes: + label: golem15.seo::lang.settings.hours_closes + comment: golem15.seo::lang.settings.hours_time_comment + type: text + span: right + verify_google: label: golem15.seo::lang.settings.verify_google comment: golem15.seo::lang.settings.verify_code_comment diff --git a/settings_test.go b/settings_test.go index 94a9761..417365b 100644 --- a/settings_test.go +++ b/settings_test.go @@ -31,7 +31,7 @@ func TestSettingsTypeTabs(t *testing.T) { env := bootEnv(t) token := env.admin(t, true) fields := env.formSchema(t, token, "golem15/seo/settings") - if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Links | Verification"; got != want { + if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Links | Publisher | Local business | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } findField(t, fields, "type_acme_page_inject") @@ -81,7 +81,7 @@ func TestSettingsTypeTabs(t *testing.T) { t.Run("no other content types", func(t *testing.T) { env := bootPlugins(t, []string{"golem15.seo"}) token := env.admin(t, true) - if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Links | Verification"; got != want { + if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Links | Publisher | Local business | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } }) diff --git a/structured_data_test.go b/structured_data_test.go index 5e16357..26614ba 100644 --- a/structured_data_test.go +++ b/structured_data_test.go @@ -3,6 +3,8 @@ package seo_test import ( "context" "encoding/json" + "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -365,3 +367,113 @@ func crumbNames(res map[string]any) string { } return strings.Join(names, " > ") } + +// refusal asserts a 422 save answer naming field with message. +func refusal(t *testing.T, rec *httptest.ResponseRecorder, field, message string) { + t.Helper() + body := rec.Body.String() + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(body, `"`+field+`"`) || !strings.Contains(body, message) { + t.Fatalf("save = %d %s, want 422 on %s with %q", rec.Code, body, field, message) + } +} + +// TestPublisherSettings covers the Publisher and Local business tabs +// (UI-SPEC C20, D-45, D-03): the saved values, every C20 refusal (422) in +// the admin and the API, the local business node and the P8 preview. +func TestPublisherSettings(t *testing.T) { + env := bootBlog(t) + token := env.admin(t, true) + fields := env.formSchema(t, token, "golem15/seo/settings") + for _, name := range []string{"breadcrumb_home", "publisher_type", "publisher_name", "publisher_logo", "publisher_same_as", "local_enabled", "local_hours", "_publisher_preview"} { + findField(t, fields, name) + } + settings := adminAPI("/golem15/seo/settings/1") + env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{ + "publisher_type": "organization", "publisher_name": "Acme Sp. z o.o.", "publisher_url": "https://acme.example.test/", + "publisher_email": "office@acme.example.test", "publisher_phone": "+48 600 000 000", + "publisher_same_as": "https://www.facebook.com/acme\nhttps://pl.wikipedia.org/wiki/Acme", + }) + show := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/settings", token, nil).Body.String() + for _, want := range []string{`"publisher_name":"Acme Sp. z o.o."`, `"publisher_phone":"+48 600 000 000"`, `"local_enabled":false`} { + if !jsonContains(show, want) { + t.Fatalf("settings lack %s: %s", want, show) + } + } + + // P8 without a logo: the node and a recommended-logo warning. + preview := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/partials/publisher_preview?id=1"), token, nil).Body.String() + if !strings.Contains(preview, "Acme Sp. z o.o.") || !strings.Contains(preview, `Missing recommended property “logo” (Organization)`) { + t.Fatalf("P8 = %s", preview) + } + env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{"publisher_logo": "/media/logo.png"}) + preview = env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/partials/publisher_preview?id=1"), token, nil).Body.String() + if !strings.Contains(preview, "No problems") || strings.Contains(preview, "Missing") { + t.Fatalf("P8 with a logo = %s", preview) + } + + // The C20 refusals, in the admin form. + refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_same_as": "https://ok.example.test\nx.pl"}), "publisher_same_as", "Line 2: use a full address starting with https://.") + refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_same_as": "x.pl"}), "publisher_same_as", "Line 1: use a full address") + refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_type": "person", "local_enabled": true, "local_street": "Prosta 1", "local_postal_code": "00-001", "local_city": "Warszawa"}), "local_enabled", "A person cannot be a local business.") + local := map[string]any{"publisher_type": "organization", "local_enabled": true, "local_street": "Prosta 1", "local_postal_code": "00-001", "local_city": "Warszawa"} + with := func(k string, v any) map[string]any { + out := map[string]any{} + for key, value := range local { + out[key] = value + } + out[k] = v + return out + } + refusal(t, env.call(t, http.MethodPut, settings, token, with("local_street", "")), "local_street", "A local business needs a street, a postal code and a city.") + refusal(t, env.call(t, http.MethodPut, settings, token, with("local_hours", []map[string]any{{"day": "mon", "opens": "9:00", "closes": "17:00"}})), "local_hours.0.opens", "Use 24-hour time, e.g. 09:00.") + refusal(t, env.call(t, http.MethodPut, settings, token, with("local_hours", []map[string]any{{"day": "mon", "opens": "17:00", "closes": "09:00"}})), "local_hours.0.closes", "Closing must come after opening.") + refusal(t, env.call(t, http.MethodPut, settings, token, with("local_country", "POL")), "local_country", "Use a two-letter country code, e.g. PL.") + refusal(t, env.call(t, http.MethodPut, settings, token, with("local_latitude", "91")), "local_latitude", "Enter decimal degrees") + // The same checks through the API (MCP seo_update_settings). + refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"local_enabled": true, "publisher_type": "person"}), "local_enabled", "A person cannot be a local business.") + refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"publisher_same_as": "x.pl"}), "publisher_same_as", "Line 1: use a full address") + + // A valid local business: the publisher node becomes its type with + // the address, geo, opening hours and price range. + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{ + "local_enabled": true, "local_type": "FoodEstablishment", "local_street": "Prosta 1", "local_postal_code": "00-001", + "local_city": "Warszawa", "local_country": "pl", "local_latitude": "52.2297", "local_longitude": "21.0122", + "local_price_range": "$$", "local_hours": []map[string]any{{"day": "mon", "opens": "09:00", "closes": "13:00"}, {"day": "mon", "opens": "14:00", "closes": "18:00"}}, + }) + nodes := graphNodes(t, env.resolve(t, "/")) + biz := nodes["FoodEstablishment"] + if biz == nil || biz["@id"] != siteURL+"/#organization" || biz["priceRange"] != "$$" || biz["telephone"] != "+48 600 000 000" { + t.Fatalf("local business node = %v", nodes) + } + if got := asJSON(t, biz["address"]); got != `{"@type":"PostalAddress","addressCountry":"PL","addressLocality":"Warszawa","postalCode":"00-001","streetAddress":"Prosta 1"}` { + t.Fatalf("address = %s", got) + } + if got := asJSON(t, biz["geo"]); got != `{"@type":"GeoCoordinates","latitude":52.2297,"longitude":21.0122}` { + t.Fatalf("geo = %s", got) + } + if hours, _ := biz["openingHoursSpecification"].([]any); len(hours) != 2 || hours[1].(map[string]any)["opens"] != "14:00" || hours[0].(map[string]any)["dayOfWeek"] != "Monday" { + t.Fatalf("hours = %v", biz["openingHoursSpecification"]) + } + if got := asJSON(t, biz["sameAs"]); got != `["https://www.facebook.com/acme","https://pl.wikipedia.org/wiki/Acme"]` { + t.Fatalf("sameAs = %s", got) + } + if biz["url"] != "https://acme.example.test/" || asJSON(t, biz["logo"]) != `{"@id":"https://www.example.test/#logo","@type":"ImageObject","contentUrl":"https://www.example.test/media/logo.png","url":"https://www.example.test/media/logo.png"}` { + t.Fatalf("url/logo = %v %v", biz["url"], biz["logo"]) + } + + // A person publisher: #person, no local business. + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"local_enabled": false, "publisher_type": "person", "publisher_name": "Jan Kowalski"}) + nodes = graphNodes(t, env.resolve(t, "/")) + if p := nodes["Person"]; p == nil || p["@id"] != siteURL+"/#person" || p["name"] != "Jan Kowalski" || idOf(nodes["WebSite"]["publisher"]) != siteURL+"/#person" { + t.Fatalf("person publisher = %v", nodes) + } + + // The home crumb name. + if got := crumbNames(env.resolve(t, "/blog/solo")); got != "Home > Solo" { + t.Fatalf("default home crumb trail = %s", got) + } + env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{"breadcrumb_home": "Start"}) + if got := crumbNames(env.resolve(t, "/blog/solo")); got != "Start > Solo" { + t.Fatalf("home crumb trail = %s", got) + } +}