feat: publisher, local business and home crumb settings
- Publisher and Local business tabs with the C20 refusals (sameAs lines, person as local business, address, times, country, coordinates) - the publisher node from the settings, as an Organization, a Person or the chosen local business type with address, geo and opening hours - P8 publisher preview with Google's required and recommended findings (classes/schema Validate) - the home crumb name on the General tab; every new key in the settings API and seo_update_settings
This commit is contained in:
153
classes/schema/validate.go
Normal file
153
classes/schema/validate.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user