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:
@@ -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"`
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user