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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
14
controllers/settings/_publisher_preview.htm
Normal file
14
controllers/settings/_publisher_preview.htm
Normal file
@@ -0,0 +1,14 @@
|
||||
{{- if .Data.JSON -}}
|
||||
<div class="summer-markdown">
|
||||
<pre>{{ .Data.JSON }}</pre>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- range .Data.Findings -}}
|
||||
<div class="summer-callout summer-callout--warning" role="status">
|
||||
<p class="summer-callout__text">{{ . }}</p>
|
||||
</div>
|
||||
{{- else -}}
|
||||
<div class="summer-callout" role="status">
|
||||
<p class="summer-callout__text">{{ .Data.AllGood }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.<i>.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user