feat: auto-link phrases in the resolve answer (tracer)

- golem15_seo_autolinks (migration 202610280007) and the settings column
  autolink_max (Links tab, 0 to 20, default 3)
- classes/autolinks.go: checked save and delete with history, ForPage
- ok resolve answers carry autolinks for the page's locale without
  disabled phrases and links to the page itself, and autolink_max
This commit is contained in:
Jakub Zych
2026-11-04 16:22:23 +01:00
parent b553fc2937
commit 6203aec2bc
13 changed files with 671 additions and 9 deletions

296
classes/autolinks.go Normal file
View File

@@ -0,0 +1,296 @@
package classes
import (
"context"
"errors"
"net/url"
"strings"
"time"
"unicode"
"unicode/utf8"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// MaxPageAutolinks caps the phrases one resolve answer carries.
const MaxPageAutolinks = 1000
// AutolinkLink is one phrase of a resolve answer's autolinks (D-82): the
// layer links the phrase's first occurrence in a text to URL.
type AutolinkLink struct {
Phrase string `json:"phrase"`
URL string `json:"url"`
}
// AutolinkValues are an auto-link's values for history and the API.
func AutolinkValues(a models.Autolink) map[string]any {
return map[string]any{
"phrase": a.Phrase, "url": a.URL, "locale": a.Locale,
"enabled": a.Enabled, "origin": a.Origin, "note": a.Note,
}
}
// AutolinkWrite is a create or update of an auto-link: only the fields
// set change.
type AutolinkWrite struct {
Phrase *string `json:"phrase,omitempty"`
URL *string `json:"url,omitempty"`
Locale *string `json:"locale,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Note *string `json:"note,omitempty"`
}
// Apply copies the fields set into a.
func (w AutolinkWrite) Apply(a *models.Autolink) {
if w.Phrase != nil {
a.Phrase = *w.Phrase
}
if w.URL != nil {
a.URL = *w.URL
}
if w.Locale != nil {
a.Locale = *w.Locale
}
if w.Enabled != nil {
a.Enabled = *w.Enabled
}
if w.Note != nil {
a.Note = *w.Note
}
}
// NormalizeAutolink trims the phrase (inner whitespace collapsed to one
// space), the address, the locale ("all" is every locale) and the note.
func NormalizeAutolink(a *models.Autolink) {
a.Phrase = strings.Join(strings.Fields(a.Phrase), " ")
a.URL = strings.TrimSpace(a.URL)
a.Locale = strings.TrimSpace(a.Locale)
if a.Locale == models.AutolinkLocaleAll {
a.Locale = ""
}
a.Note = strings.TrimSpace(a.Note)
if a.Origin == "" {
a.Origin = models.AutolinkOriginManual
}
}
// ValidAutolinkURL reports whether u is a path on the site (starting with
// one /) or an absolute http(s) address, without whitespace or control
// characters (T-33-50: javascript:, data: and protocol-relative addresses
// are refused).
func ValidAutolinkURL(u string) bool {
if u == "" || len(u) > models.MaxAutolinkURL || !utf8.ValidString(u) {
return false
}
for _, r := range u {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return false
}
}
if strings.HasPrefix(u, "/") {
return !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, `/\`)
}
parsed, err := url.Parse(u)
if err != nil {
return false
}
scheme := strings.ToLower(parsed.Scheme)
return (scheme == "http" || scheme == "https") && parsed.Host != "" && parsed.User == nil
}
// AutolinkProblems refuses a phrase under 3 characters (or over 200), an
// address that is not a path or an http(s) address, an unknown locale,
// and a phrase another row already has in the same locale (a phrase of
// every locale meets the phrases of each locale), naming that row's
// address (UI-SPEC C22). a is normalised first.
func (s *Service) AutolinkProblems(ctx context.Context, db *gorm.DB, a *models.Autolink) (map[string][]models.Problem, error) {
NormalizeAutolink(a)
out := map[string][]models.Problem{}
n := utf8.RuneCountInString(a.Phrase)
switch {
case n < models.MinAutolinkPhrase:
out["phrase"] = append(out["phrase"], models.Problem{Key: "golem15.seo::lang.autolinks.phrase_short"})
case n > models.MaxAutolinkPhrase:
out["phrase"] = append(out["phrase"], models.Problem{Key: "lagoon::validation.max.string", Params: map[string]string{"attribute": "phrase", "max": "200"}})
}
if !ValidAutolinkURL(a.URL) {
out["url"] = append(out["url"], models.Problem{Key: "golem15.seo::lang.autolinks.url_invalid"})
}
if a.Locale != "" {
known := false
for _, code := range s.Locales(ctx, db) {
if code == a.Locale {
known = true
break
}
}
if !known {
out["locale"] = append(out["locale"], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
}
if len(out["phrase"]) == 0 {
q := db.Model(&models.Autolink{}).Where("lower(phrase) = lower(?)", a.Phrase)
if a.Locale != "" {
q = q.Where("locale = ? OR locale = ''", a.Locale)
}
if a.ID != 0 {
q = q.Where("id <> ?", a.ID)
}
var rows []models.Autolink
if err := q.Order("id ASC").Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) > 0 {
out["phrase"] = append(out["phrase"], models.Problem{Key: "golem15.seo::lang.autolinks.phrase_taken", Params: map[string]string{"url": rows[0].URL}})
}
}
return out, nil
}
// LoadAutolink returns auto-link id; ErrNoRecord when there is none.
func LoadAutolink(db *gorm.DB, id uint) (models.Autolink, error) {
var rows []models.Autolink
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.Autolink{}, err
}
if len(rows) == 0 {
return models.Autolink{}, ErrNoRecord
}
return rows[0], nil
}
// AutolinkOrigin is the origin of a phrase created through ctx: mcp
// through MCP, else manual.
func AutolinkOrigin(ctx context.Context) string {
if ViaOf(ctx) == models.ViaMCP {
return models.AutolinkOriginMCP
}
return models.AutolinkOriginManual
}
// autolinkWhat is an auto-link's history description.
func (s *Service) autolinkWhat(a models.Autolink) string {
return s.Phrase("golem15.seo::lang.history.what_autolink", map[string]string{"phrase": a.Phrase})
}
// RecordAutolink records an auto-link's change in history inside tx; the
// write purges the site's cached pages, since the phrase may appear in any
// text. before is nil for a created phrase.
func (s *Service) RecordAutolink(ctx context.Context, tx *gorm.DB, before map[string]any, a models.Autolink, deleted bool) error {
c := Change{Kind: models.SubjectAutolink, SubjectID: a.IDText(), What: s.autolinkWhat(a), Before: before}
if !deleted {
c.After = AutolinkValues(a)
}
return s.Record(ctx, tx, c)
}
// SaveAutolink checks and writes an auto-link inside tx and records it in
// history; a refused phrase is a *ProblemsError.
func (s *Service) SaveAutolink(ctx context.Context, tx *gorm.DB, a *models.Autolink) error {
if a == nil {
return errors.New("golem15.seo: no auto-link")
}
problems, err := s.AutolinkProblems(ctx, tx, a)
if err != nil {
return err
}
if len(problems) > 0 {
return &ProblemsError{Problems: problems}
}
now := time.Now().UTC()
var before map[string]any
if a.ID == 0 {
a.CreatedAt, a.UpdatedAt = &now, &now
if err := tx.Create(a).Error; err != nil {
return err
}
} else {
old, err := LoadAutolink(tx, a.ID)
if err != nil {
return err
}
before = AutolinkValues(old)
a.CreatedAt, a.UpdatedAt = old.CreatedAt, &now
if err := tx.Save(a).Error; err != nil {
return err
}
}
return s.RecordAutolink(ctx, tx, before, *a, false)
}
// DeleteAutolinks deletes the auto-links ids inside tx, records each in
// history and returns the ids deleted (unknown ids are skipped).
func (s *Service) DeleteAutolinks(ctx context.Context, tx *gorm.DB, ids []uint) ([]uint, error) {
if len(ids) == 0 {
return []uint{}, nil
}
var rows []models.Autolink
if err := tx.Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]uint, 0, len(rows))
for _, row := range rows {
if err := tx.Delete(&models.Autolink{}, row.ID).Error; err != nil {
return nil, err
}
if err := s.RecordAutolink(ctx, tx, AutolinkValues(row), row, true); err != nil {
return nil, err
}
out = append(out, row.ID)
}
return out, nil
}
// autolinkPath is the site path an auto-link's address names: the path of
// a / address, or of an absolute address on the site's host (its www twin
// included); "" for another site.
func (s *Service) autolinkPath(target string) string {
if strings.HasPrefix(target, "/") {
path, _, _ := strings.Cut(target, "#")
path, _, _ = strings.Cut(path, "?")
return path
}
u, err := url.Parse(target)
if err != nil {
return ""
}
site := strings.TrimPrefix(strings.ToLower(s.host()), "www.")
if site == "" || strings.TrimPrefix(strings.ToLower(u.Host), "www.") != site {
return ""
}
if u.Path == "" {
return "/"
}
return u.Path
}
// ForPage lists the auto-link phrases of a page in locale (D-82): the
// active phrases of that locale and of every locale, without those that
// link to the page itself (selfPath, a trailing slash ignored), longest
// phrase first so a longer phrase wins over a phrase it contains, one row
// per phrase, at most MaxPageAutolinks.
func (s *Service) ForPage(ctx context.Context, db *gorm.DB, locale, selfPath string) ([]AutolinkLink, error) {
var rows []models.Autolink
err := db.WithContext(ctx).Where("enabled AND (locale = '' OR locale = ?)", locale).
Order("char_length(phrase) DESC, lower(phrase) ASC, locale DESC, id ASC").
Limit(MaxPageAutolinks).Find(&rows).Error
if err != nil {
return nil, err
}
self := trimSlash(selfPath)
seen := map[string]bool{}
out := make([]AutolinkLink, 0, len(rows))
for _, row := range rows {
key := strings.ToLower(row.Phrase)
if seen[key] {
continue
}
if p := s.autolinkPath(row.URL); p != "" && trimSlash(p) == self {
continue
}
seen[key] = true
out = append(out, AutolinkLink{Phrase: row.Phrase, URL: row.URL})
}
return out, nil
}

View File

@@ -295,10 +295,11 @@ var (
purging map[gorm.ConnPool]bool
)
// purgeSlug is the purge's slug: "*" for the settings and robots.txt,
// else the changed object's address.
// purgeSlug is the purge's slug: "*" for the settings, robots.txt and an
// auto-link phrase (it may appear in any text), else the changed object's
// address.
func purgeSlug(c Change) string {
if c.Kind == models.SubjectSettings || c.Kind == models.SubjectRobots {
if c.Kind == models.SubjectSettings || c.Kind == models.SubjectRobots || c.Kind == models.SubjectAutolink {
return "*"
}
for _, values := range []map[string]any{c.After, c.Before} {

View File

@@ -67,6 +67,12 @@ type Result struct {
// Outbound is how the layer renders links to other sites (P33-16),
// present on ok answers.
Outbound *Outbound `json:"outbound,omitempty"`
// Autolinks are the auto-link phrases of the page's locale without
// those linking to the page itself, longest first, and AutolinkMax the
// most the layer links in one text (D-82); present on ok answers, no
// phrases when AutolinkMax is 0.
Autolinks []AutolinkLink `json:"autolinks,omitempty"`
AutolinkMax *int `json:"autolink_max,omitempty"`
// HTMLLang is the page's <html lang> (P33-21); Alternates its hreflang
// list, the page itself and x-default included (D-55 to D-57); JSONLD
// its one @graph (D-43); Breadcrumbs its trail, the same items as the
@@ -799,8 +805,15 @@ 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)
// The sitemap filter (production) decides on robots and canonical
// only; it skips the structured data.
// only; it skips the auto-links and the structured data.
if !r.production {
most := min(models.MaxAutolinkMax, max(models.MinAutolinkMax, settings.AutolinkMax))
res.AutolinkMax = &most
if most > 0 {
if res.Autolinks, err = svc.ForPage(ctx, db, locale, path); err != nil {
return Result{}, err
}
}
if err := svc.structuredData(ctx, db, settings, &res, structuredIn{
key: key, id: res.RecordID, rec: rec, entry: entry, content: content,
locale: locale, path: path, url: self, home: home, siteName: svc.SiteName(ctx, settings, locale), seo: seo,

View File

@@ -48,7 +48,7 @@ var SettingsColumns = []string{
"local_latitude", "local_longitude", "local_hours", "page_form",
"page_segment", "page_title_suffix", "page_description_suffix",
"crawl_schedule", "crawl_max_pages", "crawl_delay", "crawl_keep",
"generic_anchors",
"generic_anchors", "autolink_max",
}
// settingsText maps the plain text settings columns to their fields.
@@ -102,6 +102,7 @@ func settingsInts(st *models.Settings) map[string]*int {
"crawl_max_pages": &st.CrawlMaxPages,
"crawl_delay": &st.CrawlDelay,
"crawl_keep": &st.CrawlKeep,
"autolink_max": &st.AutolinkMax,
}
}
@@ -158,6 +159,7 @@ func SettingsValues(st models.Settings) map[string]any {
"crawl_delay": st.CrawlDelay,
"crawl_keep": st.CrawlKeep,
"generic_anchors": st.GenericAnchors,
"autolink_max": st.AutolinkMax,
}
}

View File

@@ -28,6 +28,7 @@ type UpdateSettingsInput struct {
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)"`
AutolinkMax *int `json:"autolink_max,omitempty" jsonschema:"the most phrases of the auto-link table linked in one text, 0 to 20 (default 3); 0 turns auto-linking off"`
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

@@ -108,6 +108,8 @@ settings:
tab_urls: Addresses
tab_verification: Verification
tab_links: Links
autolink_max: Auto-links per page
autolink_max_comment: "At most this many phrases from SEO › Auto-links are linked in one text. 0 turns auto-linking off."
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
@@ -294,6 +296,7 @@ history:
what_settings: SEO settings
what_notfound: 404 log entry “:url”
what_page: Page “:url”
what_autolink: Auto-link “:phrase”
change_cell:
one: ':field: :before → :after'
other: ':count fields changed'
@@ -518,6 +521,38 @@ 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
autolinks:
list_title: Auto-links
name: Auto-link
new: New auto-link
list_empty: "No auto-links yet. Add a phrase and the page it should link to; texts then link its first occurrence automatically."
from_keyphrases: Add focus keyphrases
phrase: Phrase
url: Links to
locale: Language
origin: Origin
updated: Updated
locale_all: All languages
origin_manual: Manual
origin_keyphrase: Focus keyphrase
origin_mcp: MCP
enabled: Active
filter_active: Active only
state_off: "Off"
phrase_comment: "Linked at its first occurrence in a text, as whole words, ignoring letter case. Never inside headings or existing links, and never on the page it links to."
url_comment: "A path such as /grzyby/lwia-grzywa, or a full address."
note: Note
keyphrases_added:
one: ":count phrase added from focus keyphrases."
other: ":count phrases added from focus keyphrases."
keyphrases_none: "No new focus keyphrases to add."
phrase_short: "Use at least 3 characters."
phrase_taken: "This phrase already links to “:url” in this language."
url_invalid: "Use a path starting with / or a full http(s) address."
delete_confirm: "Delete this auto-link? Texts stop linking the phrase when their pages are next rendered."
delete_selected_confirm:
one: "Delete :count auto-link?"
other: "Delete :count auto-links?"
graph:
action: "Check structured data"
required: "Missing required property “:property” (:type)"

View File

@@ -110,6 +110,8 @@ settings:
tab_urls: Adresy
tab_verification: Weryfikacja
tab_links: Linki
autolink_max: Automatyczne linki na stronę
autolink_max_comment: "Najwyżej tyle fraz z SEO › Automatyczne linki zostaje podlinkowanych w jednym tekście. 0 wyłącza automatyczne linkowanie."
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
@@ -298,6 +300,7 @@ history:
what_settings: Ustawienia SEO
what_notfound: Wpis dziennika 404 „:url”
what_page: Strona „:url”
what_autolink: Automatyczny link „:phrase”
change_cell:
one: ':field: :before → :after'
few: Zmieniono :count pola
@@ -532,6 +535,42 @@ 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
autolinks:
list_title: Automatyczne linki
name: Automatyczny link
new: Nowy automatyczny link
list_empty: "Nie ma jeszcze automatycznych linków. Dodaj frazę i stronę, do której ma prowadzić; teksty będą wtedy automatycznie linkować jej pierwsze wystąpienie."
from_keyphrases: Dodaj frazy kluczowe
phrase: Fraza
url: Prowadzi do
locale: Język
origin: Pochodzenie
updated: Zaktualizowano
locale_all: Wszystkie języki
origin_manual: Ręcznie
origin_keyphrase: Fraza kluczowa
origin_mcp: MCP
enabled: Aktywny
filter_active: Tylko aktywne
state_off: Wyłączony
phrase_comment: "Linkowana przy pierwszym wystąpieniu w tekście, jako całe słowa, bez względu na wielkość liter. Nigdy w nagłówkach ani istniejących linkach i nigdy na stronie, do której prowadzi."
url_comment: "Ścieżka, np. /grzyby/lwia-grzywa, lub pełny adres."
note: Notatka
keyphrases_added:
one: "Dodano :count frazę z fraz kluczowych."
few: "Dodano :count frazy z fraz kluczowych."
many: "Dodano :count fraz z fraz kluczowych."
other: "Dodano :count frazy z fraz kluczowych."
keyphrases_none: "Brak nowych fraz kluczowych do dodania."
phrase_short: "Użyj co najmniej 3 znaków."
phrase_taken: "Ta fraza już prowadzi do „:url” w tym języku."
url_invalid: "Użyj ścieżki zaczynającej się od / lub pełnego adresu http(s)."
delete_confirm: "Usunąć ten automatyczny link? Teksty przestaną linkować frazę przy następnym wygenerowaniu stron."
delete_selected_confirm:
one: "Usunąć :count automatyczny link?"
few: "Usunąć :count automatyczne linki?"
many: "Usunąć :count automatycznych linków?"
other: "Usunąć :count automatycznego linku?"
graph:
action: "Sprawdź dane strukturalne"
required: "Brak wymaganej właściwości „:property” (:type)"

99
linking_test.go Normal file
View File

@@ -0,0 +1,99 @@
package seo_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// autolinksOf reads the resolve answer's autolinks as phrase → url, and
// its autolink_max (-1 when absent).
func autolinksOf(t *testing.T, got map[string]any) (map[string]string, int) {
t.Helper()
out := map[string]string{}
if list, ok := got["autolinks"].([]any); ok {
for _, item := range list {
m, _ := item.(map[string]any)
phrase, _ := m["phrase"].(string)
url, _ := m["url"].(string)
out[phrase] = url
}
}
most := -1
if n, ok := got["autolink_max"].(float64); ok {
most = int(n)
}
return out, most
}
// saveAutolink writes an auto-link through the service, as the screen and
// the API do.
func (e *testEnv) saveAutolink(t *testing.T, a models.Autolink) models.Autolink {
t.Helper()
svc := e.seoPlugin(t).Service()
err := lagoon.Transaction(t.Context(), e.db, func(ctx context.Context, tx *gorm.DB) error {
return svc.SaveAutolink(classes.WithTx(ctx, tx), tx, &a)
})
if err != nil {
t.Fatalf("save auto-link %q: %v", a.Phrase, err)
}
return a
}
// TestAutolinksTracer is the tracer of D-82's auto-links: an editor adds
// the phrase "lwia grzywa" for every locale, and the next page that
// resolves carries it with autolink_max; the page it links to does not,
// nor does a disabled phrase or a phrase of another locale; autolink_max 0
// answers none.
func TestAutolinksTracer(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
env.expect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/pages", token, map[string]any{"url": "/grzyby/lwia-grzywa", "title": "Lwia grzywa"})
env.createPage(t, token, map[string]any{"title": "Other", "slug": "other", "published": true})
env.saveAutolink(t, models.Autolink{Phrase: " lwia grzywa ", URL: "/grzyby/lwia-grzywa", Enabled: true})
env.saveAutolink(t, models.Autolink{Phrase: "boczniak", URL: "/grzyby/boczniak", Enabled: false})
env.saveAutolink(t, models.Autolink{Phrase: "lion's mane", URL: "/lions-mane", Locale: "en", Enabled: true})
links, most := autolinksOf(t, env.resolve(t, "/pl/pages/other"))
if most != 3 || len(links) != 1 || links["lwia grzywa"] != "/grzyby/lwia-grzywa" {
t.Fatalf("pl page autolinks = %v, autolink_max %d; want only lwia grzywa and 3", links, most)
}
links, _ = autolinksOf(t, env.resolve(t, "/pages/other"))
if len(links) != 2 || links["lion's mane"] != "/lions-mane" || links["lwia grzywa"] == "" {
t.Fatalf("en page autolinks = %v, want lwia grzywa and the en phrase", links)
}
got := env.resolve(t, "/grzyby/lwia-grzywa")
if got["action"] != "ok" {
t.Fatalf("registry page resolves %v", got)
}
if links, _ = autolinksOf(t, got); links["lwia grzywa"] != "" {
t.Fatalf("the target page carries its own phrase: %v", links)
}
if got := env.resolve(t, "/pages/missing"); got["autolinks"] != nil || got["autolink_max"] != nil {
t.Fatalf("a not_found answer carries auto-links: %v", got)
}
var revisions int64
env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions WHERE subject_kind = 'autolink'`).Scan(&revisions)
if revisions != 3 {
t.Fatalf("auto-link history rows = %d, want 3", revisions)
}
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"autolink_max": 0})
got = env.resolve(t, "/pl/pages/other")
if links, most = autolinksOf(t, got); most != 0 || len(links) != 0 {
raw, _ := json.Marshal(got)
t.Fatalf("autolink_max 0 answers %s", raw)
}
rec := env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"autolink_max": 21})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("autolink_max 21 = %d %s, want 422", rec.Code, rec.Body.String())
}
}

113
models/autolink.go Normal file
View File

@@ -0,0 +1,113 @@
package models
import (
"strconv"
"time"
"gorm.io/gorm"
)
// The origins of an auto-link phrase: entered in the admin, added from a
// record's focus keyphrase, or created through MCP.
const (
AutolinkOriginManual = "manual"
AutolinkOriginKeyphrase = "keyphrase"
AutolinkOriginMCP = "mcp"
)
// AutolinkOrigins lists the origins in filter order.
var AutolinkOrigins = []string{AutolinkOriginManual, AutolinkOriginKeyphrase, AutolinkOriginMCP}
// The limits of an auto-link phrase and of the settings' autolink_max
// (UI-SPEC C20, C22).
const (
MinAutolinkPhrase = 3
MaxAutolinkPhrase = 200
MaxAutolinkURL = 2048
DefaultAutolinkMax = 3
MinAutolinkMax = 0
MaxAutolinkMax = 20
)
// Autolink is one phrase of the auto-link table on golem15_seo_autolinks
// (D-82, P33-45): texts link the phrase's first occurrence to URL (a path
// or an absolute http(s) address). Locale is the content locale it applies
// in, "" for every locale; a disabled phrase is kept but never linked.
type Autolink struct {
ID uint `gorm:"column:id;primaryKey"`
Phrase string `gorm:"column:phrase"`
URL string `gorm:"column:url"`
Locale string `gorm:"column:locale"`
Enabled bool `gorm:"column:enabled"`
Origin string `gorm:"column:origin"`
Note string `gorm:"column:note"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
}
func (Autolink) TableName() string { return "golem15_seo_autolinks" }
// MorphName names an auto-link in history and the admin.
func (Autolink) MorphName() string { return `Golem15\SEO\Models\Autolink` }
// Fillable lists the form's columns.
func (*Autolink) Fillable() []string {
return []string{"phrase", "url", "locale", "enabled", "note"}
}
// Rules is empty: the Auto-links controller and the service check a
// phrase before a save (classes.AutolinkProblems).
func (*Autolink) Rules() map[string]string { return map[string]string{} }
// NewAutolink is a phrase with the form's defaults: every locale, active,
// entered by hand.
func NewAutolink() Autolink {
return Autolink{Enabled: true, Origin: AutolinkOriginManual}
}
// IDText is the auto-link's id as text.
func (a Autolink) IDText() string { return strconv.FormatUint(uint64(a.ID), 10) }
// The filter scopes of the Auto-links list (UI-SPEC C22).
const (
FilterAutolinkLocale = "FilterAutolinkLocale"
FilterAutolinkOrigin = "FilterAutolinkOrigin"
// AutolinkLocaleAll is the locale filter's value of the phrases that
// apply in every locale.
AutolinkLocaleAll = "all"
)
// FilterScopes lists the Auto-links list's filter scopes.
func (Autolink) FilterScopes() []string {
return []string{FilterAutolinkLocale, FilterAutolinkOrigin}
}
// FilterScope narrows the Auto-links list by locale ("all" for the
// phrases of every locale) or origin; an unknown origin matches nothing.
// The active filter is a switch on the enabled column.
func (Autolink) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
switch name {
case FilterAutolinkLocale:
s, _ := value.(string)
if s == AutolinkLocaleAll {
s = ""
}
return db.Where("golem15_seo_autolinks.locale = ?", s)
case FilterAutolinkOrigin:
s, _ := value.(string)
for _, o := range AutolinkOrigins {
if o == s {
return db.Where("golem15_seo_autolinks.origin = ?", s)
}
}
return db.Where("1 = 0")
}
return db
}
func init() {
Register(Autolink{})
}

View File

@@ -25,6 +25,8 @@ const (
SubjectNotFound = "notfound"
// SubjectPage is a page of the page registry (D-33).
SubjectPage = "page"
// SubjectAutolink is a phrase of the auto-link table (D-82).
SubjectAutolink = "autolink"
)
// Revision is one recorded SEO change on golem15_seo_revisions (D-13): who

View File

@@ -179,6 +179,10 @@ type Settings struct {
LinksNewTab bool `gorm:"column:links_new_tab"`
NofollowDomains string `gorm:"column:nofollow_domains"`
SponsoredDomains string `gorm:"column:sponsored_domains"`
// AutolinkMax is the most phrases of SEO › Auto-links linked in one
// text (Links tab, 0 to 20, 3 by default); 0 turns auto-linking off
// (D-82).
AutolinkMax int `gorm:"column:autolink_max"`
// BreadcrumbHome is the home crumb's name (translatable; empty: the
// per-locale default copy).
BreadcrumbHome string `gorm:"column:breadcrumb_home"`
@@ -247,7 +251,7 @@ func (Settings) Fillable() []string {
"local_region", "local_country", "local_latitude", "local_longitude",
"local_hours", "page_form", "page_segment", "page_title_suffix",
"page_description_suffix", "crawl_schedule", "crawl_max_pages",
"crawl_delay", "crawl_keep", "generic_anchors",
"crawl_delay", "crawl_keep", "generic_anchors", "autolink_max",
}
}
@@ -264,6 +268,7 @@ func DefaultSettings() Settings {
URLHost: HostKeep,
URLSlash: SlashKeep,
LinksNewTab: true,
AutolinkMax: DefaultAutolinkMax,
PageForm: PageFormQuery,
PublisherType: PublisherOrganization,
LocalType: DefaultLocalType,
@@ -299,9 +304,9 @@ const (
// pl lists of UI-SPEC C27, one per line.
const DefaultGenericAnchors = "click here\nhere\nmore\nread more\nlearn more\nthis link\nlink\nkliknij tutaj\ntutaj\ntu\nwięcej\nczytaj więcej\nczytaj dalej\nten link"
// AuditProblems refuses Site audit values outside their ranges
// (lagoon::validation.between.numeric, UI-SPEC C20) and an unknown
// schedule. The settings screen and the API check it on save; Problems
// AuditProblems refuses Site audit values and the Links tab's
// autolink_max outside their ranges (lagoon::validation.between.numeric,
// UI-SPEC C20) and an unknown schedule. The settings screen and the API check it on save; Problems
// leaves it out, so a partial Settings value checks its templates only.
func (s Settings) AuditProblems() map[string][]Problem {
out := map[string][]Problem{}
@@ -315,6 +320,7 @@ func (s Settings) AuditProblems() map[string][]Problem {
between("crawl_max_pages", s.CrawlMaxPages, MinCrawlMaxPages, MaxCrawlMaxPages)
between("crawl_delay", s.CrawlDelay, MinCrawlDelay, MaxCrawlDelay)
between("crawl_keep", s.CrawlKeep, MinCrawlKeep, MaxCrawlKeep)
between("autolink_max", s.AutolinkMax, MinAutolinkMax, MaxAutolinkMax)
switch s.CrawlSchedule {
case CrawlOff, CrawlDaily, CrawlWeekly:
default:

View File

@@ -178,6 +178,14 @@ fields:
span: right
tab: golem15.seo::lang.settings.tab_pagination
autolink_max:
label: golem15.seo::lang.settings.autolink_max
comment: golem15.seo::lang.settings.autolink_max_comment
type: number
span: left
default: 3
tab: golem15.seo::lang.settings.tab_links
links_new_tab:
label: golem15.seo::lang.settings.links_new_tab
comment: golem15.seo::lang.settings.links_new_tab_comment

View File

@@ -0,0 +1,47 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// createAutolinks creates golem15_seo_autolinks, the auto-link phrases
// (D-82, P33-45): a phrase, the address it links to, its locale (empty for
// every locale), whether it is active, where it came from (manual,
// keyphrase or mcp) and a note, one row per phrase (any letter case) and
// locale. The settings get autolink_max, the phrases linked in one text
// at most (3 by default, 0 turns auto-linking off).
var createAutolinks = []*gormigrate.Migration{
{
ID: "202610280007_create_golem15_seo_autolinks",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`CREATE TABLE golem15_seo_autolinks (
id BIGSERIAL PRIMARY KEY,
phrase TEXT NOT NULL,
url TEXT NOT NULL,
locale TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
origin TEXT NOT NULL DEFAULT 'manual',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP(0) NULL,
updated_at TIMESTAMP(0) NULL
)`).Error; err != nil {
return err
}
if err := tx.Exec(`CREATE UNIQUE INDEX golem15_seo_autolinks_phrase ON golem15_seo_autolinks (lower(phrase), locale)`).Error; err != nil {
return err
}
return tx.Exec(`ALTER TABLE golem15_seo_settings ADD COLUMN autolink_max INTEGER NOT NULL DEFAULT 3`).Error
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE golem15_seo_settings DROP COLUMN IF EXISTS autolink_max`).Error; err != nil {
return err
}
return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_autolinks`).Error
},
},
}
func init() {
Register(createAutolinks...)
}