Files
sm-seo-plugin/controllers/robots.go
Jakub Zych 51413fbda4 feat: robots.txt editor and tester screen
- golem15.seo.robots singleton (UI-SPEC C10): staging callout and the
  served file (P4); the plugin's own extension adds the editor, the
  tester and the AI bots tab, saved to the robots row with history
- field type robotstxt (E4, live lint, size meter; Decode refuses over
  500 KB) and widget golem15-seo-robotstester (E5) on robots_test, which
  tests the unsaved text and switches through grobotstxt
- saving rules that block a noindex address answers robots.blocks_noindex
2026-10-27 11:51:41 +01:00

102 lines
3.2 KiB
Go

package controllers
import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RobotsControllerID is the robots.txt screen (UI-SPEC C10).
const RobotsControllerID = "golem15.seo.robots"
// robotsAdminController edits the robots.txt singleton (D-22, D-24,
// D-25). Its form holds the staging callout and the served file (P4); the
// editor (E4), the tester (E5) and the AI bot switches come from the
// plugin's own form extension on this controller, so the tester can read
// the unsaved editor text and switches.
type robotsAdminController struct {
app func() *backpack.App
}
func (robotsAdminController) ID() string { return RobotsControllerID }
func (robotsAdminController) ModelName() string { return `Golem15\SEO\Models\Robots` }
func (robotsAdminController) ConfigDir() string { return "controllers/robots" }
func (robotsAdminController) NewRecord() any { return &models.Robots{} }
func (robotsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// SingletonRecord returns robots row 1, creating it when the migration's
// row is gone (first or create, safe against a concurrent first request).
func (robotsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
if db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
var rows []models.Robots
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return nil, 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 nil, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, errors.New("golem15.seo: robots row is missing")
}
return &rows[0], nil
}
// StagingView is the staging callout partial's view model.
type StagingView struct {
Staging bool
}
// ServedView is P4: the file crawlers get and, outside production, the
// production version under it.
type ServedView struct {
Served string
Production string
Staging bool
}
// PartialData serves the staging callout and P4.
func (c robotsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil, err
}
staging := svc.Staging()
switch name {
case "staging":
return StagingView{Staging: staging}, nil
case "robots_served":
production, err := svc.ServedFile(ctx, true)
if err != nil {
return nil, err
}
view := ServedView{Served: production, Staging: staging}
if staging {
view.Served, view.Production = classes.NonProductionRobots, production
}
return view, nil
}
return nil, nil
}
var (
_ pact.AdminController = robotsAdminController{}
_ pact.AdminPermissioned = robotsAdminController{}
_ pact.AdminRecordSource = robotsAdminController{}
_ pact.AdminSingleton = robotsAdminController{}
_ pact.AdminPartialData = robotsAdminController{}
)