- classes/schema builds one @graph per page (WebPage, article, profile, service, BreadcrumbList, VideoObject, WebSite, publisher) linked by @id - breadcrumb trail through the primary parent chain, hreflang alternates with x-default, html_lang and og:locale in the resolve answer - migration 202610280004 adds the publisher, local business, home crumb and pagination settings columns
355 lines
13 KiB
Go
355 lines
13 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
)
|
|
|
|
// The query parameter kinds of the D-07 list.
|
|
const (
|
|
ParamContent = "content"
|
|
ParamFilter = "filter"
|
|
ParamTracking = "tracking"
|
|
)
|
|
|
|
// The address options of D-20b.
|
|
const (
|
|
HostKeep = "keep"
|
|
HostWWW = "www"
|
|
HostBare = "bare"
|
|
SlashKeep = "keep"
|
|
SlashAdd = "add"
|
|
SlashDrop = "remove"
|
|
)
|
|
|
|
// Default templates (D-03).
|
|
const (
|
|
DefaultTitleTemplate = "%title% | %site%"
|
|
DefaultHomeTemplate = "%site%"
|
|
)
|
|
|
|
// The phrase keys of the settings refusals.
|
|
const (
|
|
errVerifyFilenameInvalid = "golem15.seo::lang.settings.verify_filename_invalid"
|
|
errVerifyFilenameTaken = "golem15.seo::lang.settings.verify_filename_taken"
|
|
errTemplateUnknown = "golem15.seo::lang.settings.template_unknown"
|
|
errDomainInvalid = "golem15.seo::lang.settings.domain_invalid"
|
|
)
|
|
|
|
// 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.
|
|
type QueryParam struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// VerifyMeta is one extra verification meta tag (D-05).
|
|
type VerifyMeta struct {
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// VerifyFile is one verification file served from the site root (D-05).
|
|
type VerifyFile struct {
|
|
Filename string `json:"filename"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// The publisher types (D-45) and the page address forms (D-40).
|
|
const (
|
|
PublisherOrganization = "organization"
|
|
PublisherPerson = "person"
|
|
PageFormQuery = "query"
|
|
PageFormPath = "path"
|
|
DefaultLocalType = "LocalBusiness"
|
|
)
|
|
|
|
// OpeningHours is one opening-hours row of a local business: a day (mon
|
|
// … sun) and its opening and closing time (24-hour HH:MM).
|
|
type OpeningHours struct {
|
|
Day string `json:"day"`
|
|
Opens string `json:"opens"`
|
|
Closes string `json:"closes"`
|
|
}
|
|
|
|
// DefaultQueryParams is the seeded D-07 list.
|
|
func DefaultQueryParams() []QueryParam {
|
|
return []QueryParam{
|
|
{Name: "page", Kind: ParamContent},
|
|
{Name: "utm_*", Kind: ParamTracking},
|
|
{Name: "fbclid", Kind: ParamTracking},
|
|
{Name: "gclid", Kind: ParamTracking},
|
|
{Name: "msclkid", Kind: ParamTracking},
|
|
{Name: "sort", Kind: ParamFilter},
|
|
}
|
|
}
|
|
|
|
// Settings is the SEO settings singleton (row id 1) on golem15_seo_settings
|
|
// (D-03, D-05, D-07, D-20b, D-21, D-23). site_name and default_description
|
|
// are translatable; the other locales live with the translate plugin.
|
|
type Settings struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
SiteName string `gorm:"column:site_name"`
|
|
TwitterSite string `gorm:"column:twitter_site"`
|
|
TitleTemplate string `gorm:"column:title_template"`
|
|
HomeTemplate string `gorm:"column:home_template"`
|
|
DefaultDescription string `gorm:"column:default_description"`
|
|
DefaultOGImage string `gorm:"column:default_og_image"`
|
|
QueryParams lagoon.Jsonable[[]QueryParam] `gorm:"column:query_params"`
|
|
NoindexPaths string `gorm:"column:noindex_paths"`
|
|
SearchPaths string `gorm:"column:search_paths"`
|
|
URLHost string `gorm:"column:url_host"`
|
|
URLSlash string `gorm:"column:url_slash"`
|
|
URLLowercase bool `gorm:"column:url_lowercase"`
|
|
VerifyGoogle string `gorm:"column:verify_google"`
|
|
VerifyBing string `gorm:"column:verify_bing"`
|
|
VerifyYandex string `gorm:"column:verify_yandex"`
|
|
VerifyOther lagoon.Jsonable[[]VerifyMeta] `gorm:"column:verify_other"`
|
|
VerifyFiles lagoon.Jsonable[[]VerifyFile] `gorm:"column:verify_files"`
|
|
CSVDelimiter string `gorm:"column:csv_delimiter"`
|
|
// LinksNewTab opens links to other sites in a new tab (Links tab, on
|
|
// by default); NofollowDomains and SponsoredDomains list the domains
|
|
// whose links always get nofollow or sponsored, one per line,
|
|
// subdomains included (D-83).
|
|
LinksNewTab bool `gorm:"column:links_new_tab"`
|
|
NofollowDomains string `gorm:"column:nofollow_domains"`
|
|
SponsoredDomains string `gorm:"column:sponsored_domains"`
|
|
// BreadcrumbHome is the home crumb's name (translatable; empty: the
|
|
// per-locale default copy).
|
|
BreadcrumbHome string `gorm:"column:breadcrumb_home"`
|
|
// PageForm is the paginated address form, query (?page=N) or path
|
|
// (/<segment>/N); PageSegment the path segment, translatable per
|
|
// locale; the suffixes are added on page 2 and later (D-40, D-41).
|
|
PageForm string `gorm:"column:page_form"`
|
|
PageSegment string `gorm:"column:page_segment"`
|
|
PageTitleSuffix string `gorm:"column:page_title_suffix"`
|
|
PageDescriptionSuffix string `gorm:"column:page_description_suffix"`
|
|
// The publisher of the site (D-45): organization or person, its name
|
|
// (translatable; empty: the site name), logo or photo, website, email,
|
|
// phone and sameAs profiles (one https URL per line).
|
|
PublisherType string `gorm:"column:publisher_type"`
|
|
PublisherName string `gorm:"column:publisher_name"`
|
|
PublisherLogo string `gorm:"column:publisher_logo"`
|
|
PublisherURL string `gorm:"column:publisher_url"`
|
|
PublisherEmail string `gorm:"column:publisher_email"`
|
|
PublisherPhone string `gorm:"column:publisher_phone"`
|
|
PublisherSameAs string `gorm:"column:publisher_same_as"`
|
|
// The local business data of an organisation publisher (D-45).
|
|
LocalEnabled bool `gorm:"column:local_enabled"`
|
|
LocalType string `gorm:"column:local_type"`
|
|
LocalPriceRange string `gorm:"column:local_price_range"`
|
|
LocalStreet string `gorm:"column:local_street"`
|
|
LocalPostalCode string `gorm:"column:local_postal_code"`
|
|
LocalCity string `gorm:"column:local_city"`
|
|
LocalRegion string `gorm:"column:local_region"`
|
|
LocalCountry string `gorm:"column:local_country"`
|
|
LocalLatitude string `gorm:"column:local_latitude"`
|
|
LocalLongitude string `gorm:"column:local_longitude"`
|
|
LocalHours lagoon.Jsonable[[]OpeningHours] `gorm:"column:local_hours"`
|
|
// RoutesReportedAt is the time of the newest route report (D-33),
|
|
// written by the report only.
|
|
RoutesReportedAt *time.Time `gorm:"column:routes_reported_at"`
|
|
CreatedAt *time.Time `gorm:"column:created_at"`
|
|
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
|
}
|
|
|
|
func (Settings) TableName() string { return "golem15_seo_settings" }
|
|
|
|
// MorphName names the settings row for translations.
|
|
func (Settings) MorphName() string { return `Golem15\SEO\Models\Settings` }
|
|
|
|
// Fillable lists the form's columns.
|
|
func (Settings) Fillable() []string {
|
|
return []string{
|
|
"site_name", "twitter_site", "title_template", "home_template",
|
|
"default_description", "default_og_image", "query_params",
|
|
"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",
|
|
}
|
|
}
|
|
|
|
// Rules is empty: the settings controller checks Problems before a save.
|
|
func (Settings) Rules() map[string]string { return map[string]string{} }
|
|
|
|
// DefaultSettings is what the resolver reads when the row is missing.
|
|
func DefaultSettings() Settings {
|
|
return Settings{
|
|
ID: 1,
|
|
TitleTemplate: DefaultTitleTemplate,
|
|
HomeTemplate: DefaultHomeTemplate,
|
|
QueryParams: lagoon.Jsonable[[]QueryParam]{Data: DefaultQueryParams(), Valid: true},
|
|
URLHost: HostKeep,
|
|
URLSlash: SlashKeep,
|
|
LinksNewTab: true,
|
|
PageForm: PageFormQuery,
|
|
PublisherType: PublisherOrganization,
|
|
LocalType: DefaultLocalType,
|
|
}
|
|
}
|
|
|
|
// Hours returns the local business's opening hours.
|
|
func (s Settings) Hours() []OpeningHours { return s.LocalHours.Get() }
|
|
|
|
// Params returns the query-parameter list.
|
|
func (s Settings) Params() []QueryParam { return s.QueryParams.Get() }
|
|
|
|
// OtherMeta returns the extra verification meta tags.
|
|
func (s Settings) OtherMeta() []VerifyMeta { return s.VerifyOther.Get() }
|
|
|
|
// Files returns the verification files.
|
|
func (s Settings) Files() []VerifyFile { return s.VerifyFiles.Get() }
|
|
|
|
// verifyFilename is the allowed form of a verification file name.
|
|
var verifyFilename = regexp.MustCompile(`^[A-Za-z0-9._-]+\.(html|txt|xml)$`)
|
|
|
|
// placeholderPattern finds %name% placeholders in a template.
|
|
var placeholderPattern = regexp.MustCompile(`%[A-Za-z0-9_]*%`)
|
|
|
|
// KnownPlaceholders are the placeholders a template may use.
|
|
var KnownPlaceholders = []string{"%title%", "%description%", "%site%"}
|
|
|
|
// UnknownPlaceholder returns the first placeholder of tpl that is not
|
|
// known, "" when every one is.
|
|
func UnknownPlaceholder(tpl string) string {
|
|
for _, found := range placeholderPattern.FindAllString(tpl, -1) {
|
|
known := false
|
|
for _, k := range KnownPlaceholders {
|
|
if found == k {
|
|
known = true
|
|
break
|
|
}
|
|
}
|
|
if !known {
|
|
return found
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Problem is one field refusal: a phrase key and its :name parameters.
|
|
// The admin controllers translate it in the request locale.
|
|
type Problem struct {
|
|
Key string
|
|
Params map[string]string
|
|
}
|
|
|
|
// TemplateProblems refuses a template with an unknown placeholder.
|
|
func TemplateProblems(tpl string) []Problem {
|
|
if p := UnknownPlaceholder(tpl); p != "" {
|
|
return []Problem{{Key: errTemplateUnknown, Params: map[string]string{"placeholder": p}}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Problems lists the settings refusals per field: templates with an unknown
|
|
// placeholder, and verification file names that are malformed or used
|
|
// twice (verify_files.<i>.filename).
|
|
func (s Settings) Problems() map[string][]Problem {
|
|
out := map[string][]Problem{}
|
|
for field, tpl := range map[string]string{"title_template": s.TitleTemplate, "home_template": s.HomeTemplate} {
|
|
if p := TemplateProblems(tpl); p != nil {
|
|
out[field] = p
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
for i, f := range s.Files() {
|
|
name := strings.TrimSpace(f.Filename)
|
|
key := fmt.Sprintf("verify_files.%d.filename", i)
|
|
switch {
|
|
case !verifyFilename.MatchString(name):
|
|
out[key] = []Problem{{Key: errVerifyFilenameInvalid}}
|
|
case seen[strings.ToLower(name)]:
|
|
out[key] = []Problem{{Key: errVerifyFilenameTaken}}
|
|
}
|
|
seen[strings.ToLower(name)] = true
|
|
}
|
|
for field, list := range map[string]string{"nofollow_domains": s.NofollowDomains, "sponsored_domains": s.SponsoredDomains} {
|
|
if p := DomainProblems(list); p != nil {
|
|
out[field] = p
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// domainLabel is one label of a host name: letters (any script, written
|
|
// in lowercase), digits and inner hyphens, at most 63 bytes.
|
|
func domainLabel(label string) bool {
|
|
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
|
return false
|
|
}
|
|
for _, r := range label {
|
|
if r != '-' && !unicode.IsDigit(r) && !(unicode.IsLetter(r) && !unicode.IsUpper(r)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ValidDomain reports whether d is a host name with at least two labels,
|
|
// without a scheme, port, path or trailing dot.
|
|
func ValidDomain(d string) bool {
|
|
if d == "" || len(d) > 253 {
|
|
return false
|
|
}
|
|
labels := strings.Split(d, ".")
|
|
if len(labels) < 2 {
|
|
return false
|
|
}
|
|
for _, l := range labels {
|
|
if !domainLabel(l) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// DomainLines splits a domain list into its trimmed, lowercased,
|
|
// non-empty lines, in order and each once.
|
|
func DomainLines(list string) []string {
|
|
out := []string{}
|
|
seen := map[string]bool{}
|
|
for _, line := range strings.Split(strings.ReplaceAll(list, "\r\n", "\n"), "\n") {
|
|
d := strings.ToLower(strings.TrimSpace(line))
|
|
if d == "" || seen[d] {
|
|
continue
|
|
}
|
|
seen[d] = true
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NormalizeDomains rewrites a domain list as DomainLines, one per line.
|
|
func NormalizeDomains(list string) string {
|
|
return strings.Join(DomainLines(list), "\n")
|
|
}
|
|
|
|
// DomainProblems refuses the first line of list that is not a domain
|
|
// (settings.domain_invalid with its line number and text).
|
|
func DomainProblems(list string) []Problem {
|
|
n := 0
|
|
for _, line := range strings.Split(strings.ReplaceAll(list, "\r\n", "\n"), "\n") {
|
|
n++
|
|
d := strings.ToLower(strings.TrimSpace(line))
|
|
if d == "" || ValidDomain(d) {
|
|
continue
|
|
}
|
|
return []Problem{{Key: errDomainInvalid, Params: map[string]string{"line": strconv.Itoa(n), "value": strings.TrimSpace(line)}}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func init() {
|
|
Register(Settings{})
|
|
}
|