From 6203aec2bc77f03e90cd264da04df0782bb62a42 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 16:22:23 +0100 Subject: [PATCH] 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 --- classes/autolinks.go | 296 ++++++++++++++++++ classes/history.go | 7 +- classes/resolver.go | 15 +- classes/settings_write.go | 4 +- controllers/api/settings.go | 1 + lang/en/lang.yaml | 35 +++ lang/pl/lang.yaml | 39 +++ linking_test.go | 99 ++++++ models/autolink.go | 113 +++++++ models/revision.go | 2 + models/settings.go | 14 +- models/settings/fields.yaml | 8 + ...2610280007_create_golem15_seo_autolinks.go | 47 +++ 13 files changed, 671 insertions(+), 9 deletions(-) create mode 100644 classes/autolinks.go create mode 100644 linking_test.go create mode 100644 models/autolink.go create mode 100644 updates/202610280007_create_golem15_seo_autolinks.go diff --git a/classes/autolinks.go b/classes/autolinks.go new file mode 100644 index 0000000..0de15c1 --- /dev/null +++ b/classes/autolinks.go @@ -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 +} diff --git a/classes/history.go b/classes/history.go index e1621e6..d36dbdc 100644 --- a/classes/history.go +++ b/classes/history.go @@ -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} { diff --git a/classes/resolver.go b/classes/resolver.go index 74a50c0..d4d880e 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -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 (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, diff --git a/classes/settings_write.go b/classes/settings_write.go index a713dec..a4ef260 100644 --- a/classes/settings_write.go +++ b/classes/settings_write.go @@ -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, } } diff --git a/controllers/api/settings.go b/controllers/api/settings.go index 4b18656..6e77d4b 100644 --- a/controllers/api/settings.go +++ b/controllers/api/settings.go @@ -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"` diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index f7044e3..8516cc4 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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)" diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index c9c3eb0..4cfbee1 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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)" diff --git a/linking_test.go b/linking_test.go new file mode 100644 index 0000000..e85e60c --- /dev/null +++ b/linking_test.go @@ -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()) + } +} diff --git a/models/autolink.go b/models/autolink.go new file mode 100644 index 0000000..b3d143f --- /dev/null +++ b/models/autolink.go @@ -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{}) +} diff --git a/models/revision.go b/models/revision.go index f7bdd31..8772b46 100644 --- a/models/revision.go +++ b/models/revision.go @@ -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 diff --git a/models/settings.go b/models/settings.go index cfa9756..07a7d17 100644 --- a/models/settings.go +++ b/models/settings.go @@ -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: diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 191be22..4ea081c 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -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 diff --git a/updates/202610280007_create_golem15_seo_autolinks.go b/updates/202610280007_create_golem15_seo_autolinks.go new file mode 100644 index 0000000..988091c --- /dev/null +++ b/updates/202610280007_create_golem15_seo_autolinks.go @@ -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...) +}