Files
sm-seo-plugin/classes/robots_write.go
Jakub Zych 742a5b930b feat: site endpoint, llms.txt and the sitemap index in robots.txt
- GET /_seo/api/v1/site (public, noindex): site_url, default_locale,
  locales, unprefixed_locale, links.new_tab, html_sitemap (from the
  sitemap plugin's Listing), routes of every content type, root_files
  (verification files and the IndexNow key file) and llms
- GET /llms.txt: with llms_enabled (robots.txt screen, AI bots tab, off by
  default) and in production, the indexable pages from the sitemaps per
  group under the site name and llms_intro (else the default
  description); otherwise 404; seo_update_robots takes both fields
- robots.txt names the sitemap plugin's index unless
  golem15.seo.robots_sitemaps is set; the config default is now a comment
2026-11-04 13:32:12 +01:00

121 lines
3.7 KiB
Go

package classes
import (
"context"
"errors"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RobotsRow returns robots row 1 inside db, creating it when the
// migration's row is gone (first or create, safe against a concurrent
// first request).
func RobotsRow(db *gorm.DB) (models.Robots, error) {
var rows []models.Robots
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Robots{}, err
}
if len(rows) > 0 {
return rows[0], nil
}
row := models.Robots{ID: 1, Bots: models.BotSwitches{}}
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return models.Robots{}, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Robots{}, err
}
if len(rows) == 0 {
return models.Robots{}, errors.New("golem15.seo: robots row is missing")
}
return rows[0], nil
}
// RobotsValues are the robots row's values for history: the editor's text
// and every listed bot's switch.
func RobotsValues(r models.Robots) map[string]any {
bots := map[string]any{}
for _, b := range Bots {
bots[b.Token] = r.Bots.Allowed(b.Token)
}
return map[string]any{"content": r.Content, "bots": bots, "llms_enabled": r.LlmsEnabled, "llms_intro": r.LlmsIntro}
}
// RobotsWrite is one change of robots.txt: the editor's text when Content
// is set, and the switches of the bots listed in Bots (by token).
type RobotsWrite struct {
Content *string
Bots map[string]bool
LlmsEnabled *bool
LlmsIntro *string
}
// SaveRobots writes robots.txt inside tx and records it in history
// (D-22, D-25). A text over MaxRobotsBytes and a token that is not a
// listed AI bot are refused with a *ProblemsError. It returns the stored
// row and the noindex addresses Googlebot may no longer fetch under it
// (the robots.blocks_noindex warning).
func (s *Service) SaveRobots(ctx context.Context, tx *gorm.DB, in RobotsWrite) (models.Robots, []string, error) {
row, err := RobotsRow(tx)
if err != nil {
return models.Robots{}, nil, err
}
problems := map[string][]models.Problem{}
if in.Content != nil && len(*in.Content) > MaxRobotsBytes {
problems["content"] = append(problems["content"], models.Problem{Key: "golem15.seo::lang.robots.too_large"})
}
for token := range in.Bots {
if _, ok := BotByToken(token); !ok {
problems["bots."+token] = append(problems["bots."+token], models.Problem{Key: "golem15.seo::lang.api.bot_unknown"})
}
}
if len(problems) > 0 {
return models.Robots{}, nil, &ProblemsError{Problems: problems}
}
before := RobotsValues(row)
if in.Content != nil {
row.Content = *in.Content
}
bots := models.BotSwitches{}
for token, on := range row.Bots {
bots[token] = on
}
for token, on := range in.Bots {
b, _ := BotByToken(token)
bots[b.Token] = on
}
row.Bots = bots
if in.LlmsEnabled != nil {
row.LlmsEnabled = *in.LlmsEnabled
}
if in.LlmsIntro != nil {
row.LlmsIntro = strings.TrimSpace(*in.LlmsIntro)
}
now := time.Now().UTC()
row.UpdatedAt = &now
if err := tx.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{
"content": row.Content, "bots": row.Bots, "llms_enabled": row.LlmsEnabled, "llms_intro": row.LlmsIntro, "updated_at": now,
}).Error; err != nil {
return models.Robots{}, nil, err
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectRobots,
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_robots", nil),
Before: before,
After: RobotsValues(row),
}); err != nil {
return models.Robots{}, nil, err
}
blocked, err := s.BlockedNoindex(tx, row.Content, row.Bots)
if err != nil {
return models.Robots{}, nil, err
}
return row, blocked, nil
}