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:
296
classes/autolinks.go
Normal file
296
classes/autolinks.go
Normal 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
|
||||||
|
}
|
||||||
@@ -295,10 +295,11 @@ var (
|
|||||||
purging map[gorm.ConnPool]bool
|
purging map[gorm.ConnPool]bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// purgeSlug is the purge's slug: "*" for the settings and robots.txt,
|
// purgeSlug is the purge's slug: "*" for the settings, robots.txt and an
|
||||||
// else the changed object's address.
|
// auto-link phrase (it may appear in any text), else the changed object's
|
||||||
|
// address.
|
||||||
func purgeSlug(c Change) string {
|
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 "*"
|
return "*"
|
||||||
}
|
}
|
||||||
for _, values := range []map[string]any{c.After, c.Before} {
|
for _, values := range []map[string]any{c.After, c.Before} {
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ type Result struct {
|
|||||||
// Outbound is how the layer renders links to other sites (P33-16),
|
// Outbound is how the layer renders links to other sites (P33-16),
|
||||||
// present on ok answers.
|
// present on ok answers.
|
||||||
Outbound *Outbound `json:"outbound,omitempty"`
|
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
|
// 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
|
// 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
|
// 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.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, locale, seo
|
||||||
res.Outbound = OutboundOf(settings)
|
res.Outbound = OutboundOf(settings)
|
||||||
// The sitemap filter (production) decides on robots and canonical
|
// 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 {
|
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{
|
if err := svc.structuredData(ctx, db, settings, &res, structuredIn{
|
||||||
key: key, id: res.RecordID, rec: rec, entry: entry, content: content,
|
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,
|
locale: locale, path: path, url: self, home: home, siteName: svc.SiteName(ctx, settings, locale), seo: seo,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ var SettingsColumns = []string{
|
|||||||
"local_latitude", "local_longitude", "local_hours", "page_form",
|
"local_latitude", "local_longitude", "local_hours", "page_form",
|
||||||
"page_segment", "page_title_suffix", "page_description_suffix",
|
"page_segment", "page_title_suffix", "page_description_suffix",
|
||||||
"crawl_schedule", "crawl_max_pages", "crawl_delay", "crawl_keep",
|
"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.
|
// 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_max_pages": &st.CrawlMaxPages,
|
||||||
"crawl_delay": &st.CrawlDelay,
|
"crawl_delay": &st.CrawlDelay,
|
||||||
"crawl_keep": &st.CrawlKeep,
|
"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_delay": st.CrawlDelay,
|
||||||
"crawl_keep": st.CrawlKeep,
|
"crawl_keep": st.CrawlKeep,
|
||||||
"generic_anchors": st.GenericAnchors,
|
"generic_anchors": st.GenericAnchors,
|
||||||
|
"autolink_max": st.AutolinkMax,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
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)"`
|
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)"`
|
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"`
|
VerifyGoogle *string `json:"verify_google,omitempty" jsonschema:"the Google Search Console verification code"`
|
||||||
VerifyBing *string `json:"verify_bing,omitempty" jsonschema:"the Bing verification code"`
|
VerifyBing *string `json:"verify_bing,omitempty" jsonschema:"the Bing verification code"`
|
||||||
VerifyYandex *string `json:"verify_yandex,omitempty" jsonschema:"the Yandex verification code"`
|
VerifyYandex *string `json:"verify_yandex,omitempty" jsonschema:"the Yandex verification code"`
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ settings:
|
|||||||
tab_urls: Addresses
|
tab_urls: Addresses
|
||||||
tab_verification: Verification
|
tab_verification: Verification
|
||||||
tab_links: Links
|
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: 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."
|
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
|
nofollow_domains: Domains that always get nofollow
|
||||||
@@ -294,6 +296,7 @@ history:
|
|||||||
what_settings: SEO settings
|
what_settings: SEO settings
|
||||||
what_notfound: 404 log entry “:url”
|
what_notfound: 404 log entry “:url”
|
||||||
what_page: Page “:url”
|
what_page: Page “:url”
|
||||||
|
what_autolink: Auto-link “:phrase”
|
||||||
change_cell:
|
change_cell:
|
||||||
one: ':field: :before → :after'
|
one: ':field: :before → :after'
|
||||||
other: ':count fields changed'
|
other: ':count fields changed'
|
||||||
@@ -518,6 +521,38 @@ pages:
|
|||||||
one: "Delete :count page from the registry? Its SEO tab is deleted with it."
|
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."
|
other: "Delete :count pages from the registry? Their SEO tabs are deleted with them."
|
||||||
home_title: Home
|
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:
|
graph:
|
||||||
action: "Check structured data"
|
action: "Check structured data"
|
||||||
required: "Missing required property “:property” (:type)"
|
required: "Missing required property “:property” (:type)"
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ settings:
|
|||||||
tab_urls: Adresy
|
tab_urls: Adresy
|
||||||
tab_verification: Weryfikacja
|
tab_verification: Weryfikacja
|
||||||
tab_links: Linki
|
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: 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."
|
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
|
nofollow_domains: Domeny zawsze z nofollow
|
||||||
@@ -298,6 +300,7 @@ history:
|
|||||||
what_settings: Ustawienia SEO
|
what_settings: Ustawienia SEO
|
||||||
what_notfound: Wpis dziennika 404 „:url”
|
what_notfound: Wpis dziennika 404 „:url”
|
||||||
what_page: Strona „:url”
|
what_page: Strona „:url”
|
||||||
|
what_autolink: Automatyczny link „:phrase”
|
||||||
change_cell:
|
change_cell:
|
||||||
one: ':field: :before → :after'
|
one: ':field: :before → :after'
|
||||||
few: Zmieniono :count pola
|
few: Zmieniono :count pola
|
||||||
@@ -532,6 +535,42 @@ pages:
|
|||||||
many: "Usunąć :count stron z rejestru? Ich zakładki SEO zostaną usunięte."
|
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."
|
other: "Usunąć :count strony z rejestru? Ich zakładki SEO zostaną usunięte."
|
||||||
home_title: Strona główna
|
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:
|
graph:
|
||||||
action: "Sprawdź dane strukturalne"
|
action: "Sprawdź dane strukturalne"
|
||||||
required: "Brak wymaganej właściwości „:property” (:type)"
|
required: "Brak wymaganej właściwości „:property” (:type)"
|
||||||
|
|||||||
99
linking_test.go
Normal file
99
linking_test.go
Normal 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
113
models/autolink.go
Normal 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{})
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ const (
|
|||||||
SubjectNotFound = "notfound"
|
SubjectNotFound = "notfound"
|
||||||
// SubjectPage is a page of the page registry (D-33).
|
// SubjectPage is a page of the page registry (D-33).
|
||||||
SubjectPage = "page"
|
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
|
// Revision is one recorded SEO change on golem15_seo_revisions (D-13): who
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ type Settings struct {
|
|||||||
LinksNewTab bool `gorm:"column:links_new_tab"`
|
LinksNewTab bool `gorm:"column:links_new_tab"`
|
||||||
NofollowDomains string `gorm:"column:nofollow_domains"`
|
NofollowDomains string `gorm:"column:nofollow_domains"`
|
||||||
SponsoredDomains string `gorm:"column:sponsored_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
|
// BreadcrumbHome is the home crumb's name (translatable; empty: the
|
||||||
// per-locale default copy).
|
// per-locale default copy).
|
||||||
BreadcrumbHome string `gorm:"column:breadcrumb_home"`
|
BreadcrumbHome string `gorm:"column:breadcrumb_home"`
|
||||||
@@ -247,7 +251,7 @@ func (Settings) Fillable() []string {
|
|||||||
"local_region", "local_country", "local_latitude", "local_longitude",
|
"local_region", "local_country", "local_latitude", "local_longitude",
|
||||||
"local_hours", "page_form", "page_segment", "page_title_suffix",
|
"local_hours", "page_form", "page_segment", "page_title_suffix",
|
||||||
"page_description_suffix", "crawl_schedule", "crawl_max_pages",
|
"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,
|
URLHost: HostKeep,
|
||||||
URLSlash: SlashKeep,
|
URLSlash: SlashKeep,
|
||||||
LinksNewTab: true,
|
LinksNewTab: true,
|
||||||
|
AutolinkMax: DefaultAutolinkMax,
|
||||||
PageForm: PageFormQuery,
|
PageForm: PageFormQuery,
|
||||||
PublisherType: PublisherOrganization,
|
PublisherType: PublisherOrganization,
|
||||||
LocalType: DefaultLocalType,
|
LocalType: DefaultLocalType,
|
||||||
@@ -299,9 +304,9 @@ const (
|
|||||||
// pl lists of UI-SPEC C27, one per line.
|
// 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"
|
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
|
// AuditProblems refuses Site audit values and the Links tab's
|
||||||
// (lagoon::validation.between.numeric, UI-SPEC C20) and an unknown
|
// autolink_max outside their ranges (lagoon::validation.between.numeric,
|
||||||
// schedule. The settings screen and the API check it on save; Problems
|
// 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.
|
// leaves it out, so a partial Settings value checks its templates only.
|
||||||
func (s Settings) AuditProblems() map[string][]Problem {
|
func (s Settings) AuditProblems() map[string][]Problem {
|
||||||
out := 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_max_pages", s.CrawlMaxPages, MinCrawlMaxPages, MaxCrawlMaxPages)
|
||||||
between("crawl_delay", s.CrawlDelay, MinCrawlDelay, MaxCrawlDelay)
|
between("crawl_delay", s.CrawlDelay, MinCrawlDelay, MaxCrawlDelay)
|
||||||
between("crawl_keep", s.CrawlKeep, MinCrawlKeep, MaxCrawlKeep)
|
between("crawl_keep", s.CrawlKeep, MinCrawlKeep, MaxCrawlKeep)
|
||||||
|
between("autolink_max", s.AutolinkMax, MinAutolinkMax, MaxAutolinkMax)
|
||||||
switch s.CrawlSchedule {
|
switch s.CrawlSchedule {
|
||||||
case CrawlOff, CrawlDaily, CrawlWeekly:
|
case CrawlOff, CrawlDaily, CrawlWeekly:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ fields:
|
|||||||
span: right
|
span: right
|
||||||
tab: golem15.seo::lang.settings.tab_pagination
|
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:
|
links_new_tab:
|
||||||
label: golem15.seo::lang.settings.links_new_tab
|
label: golem15.seo::lang.settings.links_new_tab
|
||||||
comment: golem15.seo::lang.settings.links_new_tab_comment
|
comment: golem15.seo::lang.settings.links_new_tab_comment
|
||||||
|
|||||||
47
updates/202610280007_create_golem15_seo_autolinks.go
Normal file
47
updates/202610280007_create_golem15_seo_autolinks.go
Normal 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...)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user