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

@@ -57,11 +57,14 @@ type Result struct {
Status int `json:"status"`
// Location is the absolute address a redirect answer sends the visitor
// to; absent on every other action.
Location string `json:"location,omitempty"`
URL string `json:"url"`
Locale string `json:"locale"`
Staging bool `json:"staging"`
SEO *SEO `json:"seo"`
Location string `json:"location,omitempty"`
URL string `json:"url"`
Locale string `json:"locale"`
Staging bool `json:"staging"`
SEO *SEO `json:"seo"`
// Outbound is how the layer renders links to other sites (P33-16),
// present on ok answers.
Outbound *Outbound `json:"outbound,omitempty"`
Sources map[string]string `json:"-"`
// TypeKey and RecordID name the record behind the URL, "" when none.
TypeKey string `json:"-"`
@@ -80,6 +83,35 @@ type SEO struct {
Verification []MetaTag `json:"verification"`
}
// Outbound holds the settings' Links rules for links to other sites
// (D-83): open them in a new tab, and the domains (subdomains included)
// whose links always get nofollow or sponsored.
type Outbound struct {
NewTab bool `json:"new_tab"`
NofollowDomains []string `json:"nofollow_domains"`
SponsoredDomains []string `json:"sponsored_domains"`
}
// OutboundOf is the Links rules of the settings.
func OutboundOf(s models.Settings) *Outbound {
return &Outbound{
NewTab: s.LinksNewTab,
NofollowDomains: validDomains(s.NofollowDomains),
SponsoredDomains: validDomains(s.SponsoredDomains),
}
}
// validDomains lists the valid domains of a stored list.
func validDomains(list string) []string {
out := []string{}
for _, d := range models.DomainLines(list) {
if models.ValidDomain(d) {
out = append(out, d)
}
}
return out
}
// OpenGraph holds the og: values.
type OpenGraph struct {
Title string `json:"title"`
@@ -646,6 +678,7 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S
}
res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, locale, seo
res.Outbound = OutboundOf(settings)
return res, nil
}

View File

@@ -37,7 +37,8 @@ var SettingsColumns = []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",
"url_lowercase", "links_new_tab", "nofollow_domains",
"sponsored_domains", "verify_google", "verify_bing", "verify_yandex",
"verify_other", "verify_files", "csv_delimiter",
}
@@ -57,6 +58,9 @@ func SettingsValues(st models.Settings) map[string]any {
"url_host": st.URLHost,
"url_slash": st.URLSlash,
"url_lowercase": st.URLLowercase,
"links_new_tab": st.LinksNewTab,
"nofollow_domains": st.NofollowDomains,
"sponsored_domains": st.SponsoredDomains,
"verify_google": st.VerifyGoogle,
"verify_bing": st.VerifyBing,
"verify_yandex": st.VerifyYandex,
@@ -119,12 +123,22 @@ func SetSettingsValue(st *models.Settings, column string, v any) error {
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":
b, ok := v.(bool)
if !ok {
return fmt.Errorf("golem15.seo: settings %s is not a bool", column)
}
st.LinksNewTab = b
case "query_params":
var out []models.QueryParam
if err = list(&out); err == nil {
@@ -256,6 +270,8 @@ func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrit
if len(problems) > 0 {
return models.Settings{}, &ProblemsError{Problems: problems}
}
row.NofollowDomains = models.NormalizeDomains(row.NofollowDomains)
row.SponsoredDomains = models.NormalizeDomains(row.SponsoredDomains)
now := time.Now().UTC()
row.UpdatedAt = &now
if err := tx.Save(&row).Error; err != nil {

View File

@@ -25,6 +25,9 @@ type UpdateSettingsInput struct {
URLHost *string `json:"url_host,omitempty" jsonschema:"keep, www or bare"`
URLSlash *string `json:"url_slash,omitempty" jsonschema:"keep, add or remove a trailing slash"`
URLLowercase *bool `json:"url_lowercase,omitempty" jsonschema:"lowercase the ASCII letters of addresses"`
LinksNewTab *bool `json:"links_new_tab,omitempty" jsonschema:"open links to other sites in a new tab (rel=noopener, announced to screen readers); default true"`
NofollowDomains *string `json:"nofollow_domains,omitempty" jsonschema:"domains whose links always get nofollow, one per line, e.g. example.com (subdomains included; no https:// and no path)"`
SponsoredDomains *string `json:"sponsored_domains,omitempty" jsonschema:"domains whose links always get sponsored, one per line (subdomains included)"`
VerifyGoogle *string `json:"verify_google,omitempty" jsonschema:"the Google Search Console verification code"`
VerifyBing *string `json:"verify_bing,omitempty" jsonschema:"the Bing verification code"`
VerifyYandex *string `json:"verify_yandex,omitempty" jsonschema:"the Yandex verification code"`

View File

@@ -63,7 +63,12 @@ func (c settingsAdminController) FormBeforeUpdate(ctx context.Context, model any
if !ok || s == nil {
return nil
}
return problemsError(ctx, translator(c.app), s.Problems())
if err := problemsError(ctx, translator(c.app), s.Problems()); err != nil {
return err
}
s.NofollowDomains = models.NormalizeDomains(s.NofollowDomains)
s.SponsoredDomains = models.NormalizeDomains(s.SponsoredDomains)
return nil
}
// FormBeforeCreate checks a created row like an update.

View File

@@ -75,6 +75,13 @@ settings:
tab_indexing: Indexing
tab_urls: Addresses
tab_verification: Verification
tab_links: Links
links_new_tab: Open links to other sites in a new tab
links_new_tab_comment: "On: links to other sites open in a new tab with rel=noopener, and screen readers hear that they do. Off: they open in the same tab. Their nofollow, sponsored and ugc attributes apply either way."
nofollow_domains: Domains that always get nofollow
sponsored_domains: Domains that always get sponsored
domains_comment: One domain per line, e.g. example.com (subdomains included).
domain_invalid: "Line :line: “:value” is not a domain. Write it without https:// and without a path."
site_name: Site name
site_name_comment: Replaces %site% in title templates.
title_template: Title template

View File

@@ -77,6 +77,13 @@ settings:
tab_indexing: Indeksowanie
tab_urls: Adresy
tab_verification: Weryfikacja
tab_links: Linki
links_new_tab: Otwieraj linki do innych witryn w nowej karcie
links_new_tab_comment: "Włączone: linki do innych witryn otwierają się w nowej karcie z rel=noopener, a czytniki ekranu o tym informują. Wyłączone: otwierają się w tej samej karcie. Atrybuty nofollow, sponsored i ugc działają w obu przypadkach."
nofollow_domains: Domeny zawsze z nofollow
sponsored_domains: Domeny zawsze ze sponsored
domains_comment: Jedna domena w wierszu, np. example.com (razem z subdomenami).
domain_invalid: "Wiersz :line: „:value” nie jest domeną. Wpisz ją bez https:// i bez ścieżki."
site_name: Nazwa witryny
site_name_comment: Zastępuje %site% w szablonach tytułów.
title_template: Szablon tytułu

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

View File

@@ -330,3 +330,57 @@ func TestRouteReport(t *testing.T) {
t.Fatalf("missing=only after the page is reported again = %s", again)
}
}
// outboundOf reads the resolve answer's outbound rules.
func outboundOf(t *testing.T, got map[string]any) map[string]any {
t.Helper()
out, ok := got["outbound"].(map[string]any)
if !ok {
t.Fatalf("resolve answer without outbound: %v", got)
}
return out
}
// TestSettingsLinks covers the Links tab (UI-SPEC C20, D-83, P33-16): the
// new-tab switch on by default, the domain lists with their line-numbered
// refusal, the MCP-writable keys and outbound in the resolve answer.
func TestSettingsLinks(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
findField(t, env.formSchema(t, token, "golem15/seo/settings"), "nofollow_domains")
env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true})
out := outboundOf(t, env.resolve(t, "/pages/about"))
if out["new_tab"] != true || len(out["nofollow_domains"].([]any)) != 0 || len(out["sponsored_domains"].([]any)) != 0 {
t.Fatalf("default outbound = %v, want new_tab true and no domains", out)
}
if got := env.resolve(t, "/pages/missing"); got["outbound"] != nil {
t.Fatalf("a not_found answer carries outbound: %v", got)
}
rec := env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"nofollow_domains": "example.com\nhttps://x.pl/a"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `Line 2: “https://x.pl/a” is not a domain.`) {
t.Fatalf("admin save of a bad domain = %d %s, want 422 naming line 2", rec.Code, rec.Body.String())
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"nofollow_domains": "Example.com\n\n shop.example.org "})
var stored string
if err := env.db.Raw(`SELECT nofollow_domains FROM golem15_seo_settings WHERE id = 1`).Scan(&stored).Error; err != nil || stored != "example.com\nshop.example.org" {
t.Fatalf("stored nofollow_domains = %q err=%v", stored, err)
}
api := "/_seo/api/v1/settings"
rec = env.call(t, http.MethodPut, api, token, map[string]any{"sponsored_domains": "partner.pl\nnot a domain"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "sponsored_domains") || !strings.Contains(rec.Body.String(), "Line 2") {
t.Fatalf("API save of a bad domain = %d %s", rec.Code, rec.Body.String())
}
env.expect(t, http.StatusOK, http.MethodPut, api, token, map[string]any{"sponsored_domains": "partner.pl", "links_new_tab": false})
show := env.expect(t, http.StatusOK, http.MethodGet, api, token, nil).Body.String()
if !jsonContains(show, `"links_new_tab":false`) || !jsonContains(show, `"sponsored_domains":"partner.pl"`) {
t.Fatalf("settings = %s", show)
}
out = outboundOf(t, env.resolve(t, "/pages/about"))
raw, _ := json.Marshal(out)
if string(raw) != `{"new_tab":false,"nofollow_domains":["example.com","shop.example.org"],"sponsored_domains":["partner.pl"]}` {
t.Fatalf("outbound = %s", raw)
}
}

View File

@@ -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 | Verification"; got != want {
if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Links | 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 | Verification"; got != want {
if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Links | Verification"; got != want {
t.Fatalf("settings tabs = %q, want %q", got, want)
}
})

View File

@@ -0,0 +1,37 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// addLinksAndLLMS adds the SEO settings' Links tab (links_new_tab, on by
// default; nofollow_domains and sponsored_domains, one domain per line)
// and the robots.txt screen's llms.txt switch (llms_enabled, off by
// default) and introduction (llms_intro).
var addLinksAndLLMS = []*gormigrate.Migration{
{
ID: "202610280003_add_golem15_seo_links_and_llms",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE golem15_seo_settings
ADD COLUMN links_new_tab BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN nofollow_domains TEXT NOT NULL DEFAULT '',
ADD COLUMN sponsored_domains TEXT NOT NULL DEFAULT ''`).Error; err != nil {
return err
}
return tx.Exec(`ALTER TABLE golem15_seo_robots
ADD COLUMN llms_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN llms_intro TEXT NOT NULL DEFAULT ''`).Error
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE golem15_seo_robots DROP COLUMN IF EXISTS llms_enabled, DROP COLUMN IF EXISTS llms_intro`).Error; err != nil {
return err
}
return tx.Exec(`ALTER TABLE golem15_seo_settings DROP COLUMN IF EXISTS links_new_tab, DROP COLUMN IF EXISTS nofollow_domains, DROP COLUMN IF EXISTS sponsored_domains`).Error
},
},
}
func init() {
Register(addLinksAndLLMS...)
}