feat: links settings and outbound rules in resolve

- migration 202610280003: settings links_new_tab (default on),
  nofollow_domains and sponsored_domains; robots llms_enabled and
  llms_intro (used by the llms.txt commit)
- SEO settings Links tab: the new-tab switch and the two domain lists,
  one domain per line, lowercased, refused with settings.domain_invalid
  naming the line; writable through seo_update_settings
- the resolve answer of an ok page carries outbound {new_tab,
  nofollow_domains, sponsored_domains}; no existing key changes
This commit is contained in:
Jakub Zych
2026-11-04 13:26:31 +01:00
parent 26193ae4eb
commit 49d3879954
11 changed files with 283 additions and 12 deletions

View File

@@ -3,8 +3,10 @@ package models
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
@@ -37,6 +39,7 @@ 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
@@ -95,6 +98,13 @@ type Settings struct {
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"`
// RoutesReportedAt is the time of the newest route report (D-33),
// written by the report only.
RoutesReportedAt *time.Time `gorm:"column:routes_reported_at"`
@@ -114,7 +124,8 @@ func (Settings) Fillable() []string {
"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",
"verify_other", "verify_files", "links_new_tab", "nofollow_domains",
"sponsored_domains",
}
}
@@ -130,6 +141,7 @@ func DefaultSettings() Settings {
QueryParams: lagoon.Jsonable[[]QueryParam]{Data: DefaultQueryParams(), Valid: true},
URLHost: HostKeep,
URLSlash: SlashKeep,
LinksNewTab: true,
}
}
@@ -206,9 +218,82 @@ func (s Settings) Problems() map[string][]Problem {
}
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{})
}

View File

@@ -3,8 +3,8 @@
# ===================================
#
# SEO settings (UI-SPEC C11): General, one tab per content type (added by
# the plugin's form extension after General), Indexing, Addresses and
# Verification.
# the plugin's form extension after General), Indexing, Addresses, Links
# (UI-SPEC C20) and Verification.
fields:
_staging:
@@ -139,6 +139,30 @@ fields:
default: false
tab: golem15.seo::lang.settings.tab_urls
links_new_tab:
label: golem15.seo::lang.settings.links_new_tab
comment: golem15.seo::lang.settings.links_new_tab_comment
type: switch
span: right
default: true
tab: golem15.seo::lang.settings.tab_links
nofollow_domains:
label: golem15.seo::lang.settings.nofollow_domains
comment: golem15.seo::lang.settings.domains_comment
type: textarea
size: small
span: left
tab: golem15.seo::lang.settings.tab_links
sponsored_domains:
label: golem15.seo::lang.settings.sponsored_domains
comment: golem15.seo::lang.settings.domains_comment
type: textarea
size: small
span: right
tab: golem15.seo::lang.settings.tab_links
verify_google:
label: golem15.seo::lang.settings.verify_google
comment: golem15.seo::lang.settings.verify_code_comment