From 742a5b930b3001666fedf90d8f75198bbf0781c2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 13:32:12 +0100 Subject: [PATCH] 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 --- classes/llms.go | 177 ++++++++++++++++++++++++++++++++++++++ classes/robots.go | 51 +++++++++-- classes/robots_write.go | 17 +++- classes/site.go | 139 ++++++++++++++++++++++++++++++ classes/undo.go | 7 ++ config/config.yaml | 7 +- controllers/api/llms.go | 34 ++++++++ controllers/api/robots.go | 17 ++-- controllers/api/site.go | 29 +++++++ extensions_robots.go | 34 +++++++- lang/en/lang.yaml | 4 + lang/pl/lang.yaml | 4 + models/robots.go | 14 +-- registry_test.go | 122 ++++++++++++++++++++++++++ robots_test.go | 14 +-- routes.go | 4 +- 16 files changed, 645 insertions(+), 29 deletions(-) create mode 100644 classes/llms.go create mode 100644 classes/site.go create mode 100644 controllers/api/llms.go create mode 100644 controllers/api/site.go diff --git a/classes/llms.go b/classes/llms.go new file mode 100644 index 0000000..0b75df2 --- /dev/null +++ b/classes/llms.go @@ -0,0 +1,177 @@ +package classes + +import ( + "context" + "sort" + "strings" + + "git.golem15.com/golem15/sm-sitemap-plugin/contract" +) + +// MaxLLMSEntries caps the links llms.txt lists. +const MaxLLMSEntries = 10000 + +// llmsGroup is one section of llms.txt. +type llmsGroup struct { + title string + order int + key string + entries []contract.Entry +} + +// LLMSText builds /llms.txt (D-25, UI-SPEC C25): "# ", the +// introduction (llms_intro, else the default meta description) as a quote, +// then one "## " section per sitemap group with listed +// addresses in the default locale, each a "- [title](absolute url)" line. +// It reads the sitemaps through contract.Listing, so an address the +// sitemaps leave out (noindex, unpublished, canonicalised) never appears +// (T-33-20). The groups follow the content types' order, as in the HTML +// sitemap. ok is false while llms.txt is off and outside production. +func (s *Service) LLMSText(ctx context.Context) (string, bool, error) { + if s.Staging() { + return "", false, nil + } + db, err := s.DB(ctx) + if err != nil { + return "", false, err + } + robots, err := LoadRobots(db) + if err != nil { + return "", false, err + } + if !robots.LlmsEnabled { + return "", false, nil + } + settings, err := LoadSettings(db) + if err != nil { + return "", false, err + } + locale := s.DefaultLocale(ctx, db) + var b strings.Builder + name := s.SiteName(ctx, settings, locale) + if name == "" { + name = s.SiteURL() + } + b.WriteString("# " + llmsLine(name) + "\n") + intro := strings.TrimSpace(robots.LlmsIntro) + if intro == "" { + intro = strings.TrimSpace(s.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, locale)) + } + if intro != "" { + b.WriteString("\n> " + llmsLine(intro) + "\n") + } + groups, err := s.llmsGroups(ctx, locale) + if err != nil { + return "", false, err + } + for _, g := range groups { + b.WriteString("\n## " + llmsLine(g.title) + "\n\n") + for _, e := range g.entries { + title := strings.TrimSpace(e.Title) + if title == "" { + title = e.URL + } + b.WriteString("- [" + llmsText(title) + "](" + llmsURL(e.URL) + ")\n") + } + } + return b.String(), true, nil +} + +// llmsGroups reads the listed addresses of every sitemap group in locale, +// ordered by the content type of their first address (registration order), +// then by key. +func (s *Service) llmsGroups(ctx context.Context, locale string) ([]llmsGroup, error) { + l := s.listing() + if l == nil { + return nil, nil + } + st, err := l.Settings(ctx) + if err != nil { + return nil, err + } + order := map[string]int{} + if reg := s.Registry(); reg != nil { + for i, typ := range reg.ContentTypes() { + order[typ.Key] = i + } + } + total := 0 + var out []llmsGroup + for _, g := range st.Groups { + if g.Count == 0 || total >= MaxLLMSEntries { + continue + } + grp := llmsGroup{key: g.Key, order: len(order) + 1, title: groupTitle(g, locale, st.DefaultLocale)} + after := "" + for total < MaxLLMSEntries { + page, err := l.Entries(ctx, contract.EntriesQuery{Group: g.Key, Locale: locale, After: after, Limit: 1000}) + if err != nil { + return nil, err + } + for _, e := range page.Entries { + if total >= MaxLLMSEntries { + break + } + if len(grp.entries) == 0 { + if i, ok := order[e.TypeKey]; ok { + grp.order = i + } + } + grp.entries = append(grp.entries, e) + total++ + } + if page.Next == "" || page.Next == after { + break + } + after = page.Next + } + if len(grp.entries) > 0 { + out = append(out, grp) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].order != out[j].order { + return out[i].order < out[j].order + } + return out[i].key < out[j].key + }) + return out, nil +} + +// groupTitle is a group's heading in locale: its title there, else in the +// sitemap's default locale, else any, else its key. +func groupTitle(g contract.GroupInfo, locale, def string) string { + for _, l := range []string{locale, def} { + if t := strings.TrimSpace(g.Titles[l]); t != "" { + return t + } + } + keys := make([]string, 0, len(g.Titles)) + for k := range g.Titles { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if t := strings.TrimSpace(g.Titles[k]); t != "" { + return t + } + } + return g.Key +} + +// llmsLine collapses text to one line. +func llmsLine(text string) string { + return strings.Join(strings.Fields(text), " ") +} + +// llmsText is a link text: one line with the Markdown brackets and +// backslashes escaped. +func llmsText(text string) string { + return strings.NewReplacer(`\`, `\\`, `[`, `\[`, `]`, `\]`).Replace(llmsLine(text)) +} + +// llmsURL is a link target: spaces and parentheses percent-encoded, so +// the Markdown link stays whole. +func llmsURL(u string) string { + return strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E").Replace(strings.TrimSpace(u)) +} diff --git a/classes/robots.go b/classes/robots.go index 941057f..9a39f66 100644 --- a/classes/robots.go +++ b/classes/robots.go @@ -11,8 +11,8 @@ import ( ) // RobotsSitemapsConfigKey lists the sitemap addresses robots.txt names, -// one Sitemap line each, absolute on the site URL (P-23); default -// ["/sitemap.xml"]. +// one Sitemap line each, absolute on the site URL (P-23). Unset, robots.txt +// names the sitemap plugin's index (D-22, SitemapLines). const RobotsSitemapsConfigKey = "golem15.seo.robots_sitemaps" // MaxRobotsBytes is the largest robots.txt the editor stores: Google @@ -29,8 +29,49 @@ const ( FallbackRobots = "User-agent: *\nDisallow:\n" ) +// SitemapLines are the addresses of robots.txt's Sitemap lines (D-22): +// golem15.seo.robots_sitemaps when it is set, else the sitemap index of +// the sitemap plugin's contract.Listing (its IndexURL), else /sitemap.xml +// on the site URL. The per-type files are never listed: the index names +// them. +func (s *Service) SitemapLines(ctx context.Context) []string { + if s.robotsSitemapsSet() { + return s.RobotsSitemaps() + } + if l := s.listing(); l != nil { + if st, err := l.Settings(ctx); err == nil && strings.TrimSpace(st.IndexURL) != "" { + return []string{strings.TrimSpace(st.IndexURL)} + } else if err != nil { + log.Warn("golem15.seo: sitemap index address", "error", err) + } + } + return s.RobotsSitemaps() +} + +// robotsSitemapsSet reports whether golem15.seo.robots_sitemaps is set to +// a non-empty list. +func (s *Service) robotsSitemapsSet() bool { + cfg := s.Config() + if cfg == nil { + return false + } + raw, ok := cfg.Lookup(RobotsSitemapsConfigKey) + if !ok || raw == nil { + return false + } + switch v := raw.(type) { + case []any: + return len(v) > 0 + case []string: + return len(v) > 0 + case string: + return strings.TrimSpace(v) != "" + } + return false +} + // RobotsSitemaps returns the configured sitemap addresses, absolute on -// the site URL. +// the site URL; /sitemap.xml when none is configured. func (s *Service) RobotsSitemaps() []string { var list []string if cfg := s.Config(); cfg != nil { @@ -128,7 +169,7 @@ func (s *Service) ServedFile(ctx context.Context, production bool) (string, erro if err != nil { return "", err } - body := ComposeRobots(row.Content, row.Bots, s.RobotsSitemaps()) + body := ComposeRobots(row.Content, row.Bots, s.SitemapLines(ctx)) if s != nil { s.robots.Store(&body) } @@ -164,7 +205,7 @@ type RobotsTest struct { // grobotstxt, Google's matcher (D-22, D-99): longest match, Allow wins a // tie, * and $. func (s *Service) TestRobots(content string, bots models.BotSwitches, agent, path string) RobotsTest { - file := ComposeRobots(content, bots, s.RobotsSitemaps()) + file := ComposeRobots(content, bots, s.SitemapLines(context.Background())) uri := AbsoluteURL(s.SiteURL(), path) if s.SiteURL() == "" { uri = "http://localhost" + path diff --git a/classes/robots_write.go b/classes/robots_write.go index 97c775c..5545b74 100644 --- a/classes/robots_write.go +++ b/classes/robots_write.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strconv" + "strings" "time" "git.golem15.com/golem15/sm-seo-plugin/models" @@ -42,14 +43,16 @@ func RobotsValues(r models.Robots) map[string]any { for _, b := range Bots { bots[b.Token] = r.Bots.Allowed(b.Token) } - return map[string]any{"content": r.Content, "bots": bots} + 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 + Content *string + Bots map[string]bool + LlmsEnabled *bool + LlmsIntro *string } // SaveRobots writes robots.txt inside tx and records it in history @@ -87,10 +90,16 @@ func (s *Service) SaveRobots(ctx context.Context, tx *gorm.DB, in RobotsWrite) ( 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, "updated_at": now, + "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 } diff --git a/classes/site.go b/classes/site.go new file mode 100644 index 0000000..ecbd146 --- /dev/null +++ b/classes/site.go @@ -0,0 +1,139 @@ +package classes + +import ( + "context" + "strings" + + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/sm-sitemap-plugin/contract" +) + +// SiteAnswer is the answer of GET /_seo/api/v1/site (P33-17): the facts +// about the whole site the frontend layer needs, all public. SiteURL, +// DefaultLocale, Locales and UnprefixedLocale are the resolver's; Links +// the settings' new-tab switch; HTMLSitemap the sitemap plugin's HTML +// sitemap settings; Routes each content type's layered URL pattern for +// the layer's drift check; RootFiles the file names the layer serves at +// the site root (verification files, the IndexNow key file); LLMS whether +// /llms.txt is served. +type SiteAnswer struct { + SiteURL string `json:"site_url"` + DefaultLocale string `json:"default_locale"` + Locales []string `json:"locales"` + UnprefixedLocale string `json:"unprefixed_locale"` + Links SiteLinks `json:"links"` + HTMLSitemap SiteHTMLSitemap `json:"html_sitemap"` + Routes []SiteRoute `json:"routes"` + RootFiles []string `json:"root_files"` + LLMS bool `json:"llms"` +} + +// SiteLinks is the site answer's links block. +type SiteLinks struct { + NewTab bool `json:"new_tab"` +} + +// SiteHTMLSitemap is the site answer's HTML sitemap block: whether the +// sitemap plugin serves it, its path per locale and its page size. +type SiteHTMLSitemap struct { + Enabled bool `json:"enabled"` + Paths map[string]string `json:"paths"` + PageSize int `json:"page_size"` +} + +// SiteRoute is one content type's URL pattern. +type SiteRoute struct { + Type string `json:"type"` + Pattern string `json:"pattern"` +} + +// listing returns the sitemap plugin's published contract.Listing, nil +// when none is published. +func (s *Service) listing() contract.Listing { + if s == nil || s.app == nil { + return nil + } + l, ok := s.app.Lookup[contract.Listing]() + if !ok { + return nil + } + return l +} + +// Site builds the site answer. It names only public facts: no token, no +// unpublished data (T-33-19). +func (s *Service) Site(ctx context.Context) (SiteAnswer, error) { + out := SiteAnswer{ + SiteURL: s.SiteURL(), + UnprefixedLocale: s.UnprefixedLocale(), + HTMLSitemap: SiteHTMLSitemap{Paths: map[string]string{}}, + Routes: []SiteRoute{}, + RootFiles: []string{}, + } + db, err := s.DB(ctx) + if err != nil { + return out, err + } + settings, err := LoadSettings(db) + if err != nil { + return out, err + } + robots, err := LoadRobots(db) + if err != nil { + return out, err + } + out.DefaultLocale = s.DefaultLocale(ctx, db) + out.Locales = s.Locales(ctx, db) + out.Links.NewTab = settings.LinksNewTab + out.LLMS = robots.LlmsEnabled + if reg := s.Registry(); reg != nil { + for _, typ := range reg.ContentTypes() { + if typ.Key == PageTypeKey { + continue + } + out.Routes = append(out.Routes, SiteRoute{Type: typ.Key, Pattern: reg.Pattern(typ.Key, "")}) + } + } + seen := map[string]bool{} + addFile := func(name string) { + name = strings.TrimSpace(name) + if name != "" && verificationName.MatchString(name) && !seen[name] { + seen[name] = true + out.RootFiles = append(out.RootFiles, name) + } + } + for _, f := range settings.Files() { + addFile(f.Filename) + } + if l := s.listing(); l != nil { + st, err := l.Settings(ctx) + if err != nil { + return out, err + } + out.HTMLSitemap.Enabled = st.HTMLEnabled + for locale, path := range st.HTMLPaths { + if strings.TrimSpace(path) != "" { + out.HTMLSitemap.Paths[locale] = path + } + } + out.HTMLSitemap.PageSize = st.HTMLPageSize + addFile(st.KeyFile) + } + return out, nil +} + +// SiteName is the site's name in locale: the settings' site name, else +// the application name. +func (s *Service) SiteName(ctx context.Context, settings models.Settings, locale string) string { + db, err := s.DB(ctx) + name := settings.SiteName + if err == nil { + name = s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale) + } + if strings.TrimSpace(name) == "" { + if cfg := s.Config(); cfg != nil { + name = cfg.String("app.name") + } + } + return strings.TrimSpace(name) +} diff --git a/classes/undo.go b/classes/undo.go index 716edfc..152db71 100644 --- a/classes/undo.go +++ b/classes/undo.go @@ -471,6 +471,13 @@ func (s *Service) restore(ctx context.Context, tx *gorm.DB, rev models.Revision, w.Bots[token] = b } } + if v, ok := rev.Before["llms_enabled"].(bool); ok { + w.LlmsEnabled = &v + } + if v, ok := rev.Before["llms_intro"]; ok { + intro := text(v) + w.LlmsIntro = &intro + } _, _, err := s.SaveRobots(ctx, tx, w) return err case models.SubjectSettings: diff --git a/config/config.yaml b/config/config.yaml index 54ff838..6846760 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -21,8 +21,11 @@ notfound_token: "" notfound_max_rows: 10000 # # robots_sitemaps lists the sitemap addresses robots.txt names, one -# "Sitemap:" line each, made absolute on the site URL. -robots_sitemaps: ["/sitemap.xml"] +# "Sitemap:" line each, made absolute on the site URL. Unset, robots.txt +# names the sitemap plugin's index (its site address plus /sitemap.xml); +# the per-type files are never listed, the index names them. Set it only +# to name other sitemaps: +# robots_sitemaps: ["/sitemap.xml"] # # route_report_token is the shared secret the frontend server sends as # "Authorization: Bearer " with its route report to diff --git a/controllers/api/llms.go b/controllers/api/llms.go new file mode 100644 index 0000000..6a4c988 --- /dev/null +++ b/controllers/api/llms.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + + "git.golem15.com/golem15/sm-seo-plugin/classes" +) + +// LLMS serves GET /llms.txt at the site root (D-25, UI-SPEC C25): while +// llms.txt is on (the robots.txt screen's AI bots tab) and in production, +// the site's indexable pages from the sitemaps as text/plain; otherwise +// 404. +func LLMS(service func() *classes.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + svc := service() + if svc == nil { + http.Error(w, "SEO is not available", http.StatusServiceUnavailable) + return + } + body, ok, err := svc.LLMSText(r.Context()) + if err != nil { + log.Error("golem15.seo: llms.txt", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write([]byte(body)) + } +} diff --git a/controllers/api/robots.go b/controllers/api/robots.go index e94da88..984ad42 100644 --- a/controllers/api/robots.go +++ b/controllers/api/robots.go @@ -50,6 +50,9 @@ func Robots(service func() *classes.Service) http.HandlerFunc { type UpdateRobotsInput struct { Content *string `json:"content,omitempty" jsonschema:"the robots.txt rules; Sitemap lines and the AI bot rules are added when the file is served"` Bots map[string]bool `json:"bots,omitempty" jsonschema:"AI bot switches by user-agent token, for example {\"GPTBot\": false}; a bot switched off is disallowed"` + // LlmsEnabled and LlmsIntro are the llms.txt switch and introduction. + LlmsEnabled *bool `json:"llms_enabled,omitempty" jsonschema:"serve /llms.txt: the site's indexable pages from the sitemaps, for AI tools (off by default)"` + LlmsIntro *string `json:"llms_intro,omitempty" jsonschema:"a short description of the site at the top of llms.txt; empty uses the default meta description"` } // TestRobotsInput is the query of GET /_seo/api/v1/robots/test. @@ -66,12 +69,14 @@ func robotsJSON(ctx context.Context, svc *classes.Service, row models.Robots) (m for _, b := range classes.Bots { bots = append(bots, map[string]any{"token": b.Token, "operator": b.Operator, "kind": b.Kind, "allowed": row.Bots.Allowed(b.Token)}) } - production := classes.ComposeRobots(row.Content, row.Bots, svc.RobotsSitemaps()) + production := classes.ComposeRobots(row.Content, row.Bots, svc.SitemapLines(ctx)) return map[string]any{ - "content": row.Content, - "bots": bots, - "production": production, - "staging": svc.Staging(), + "content": row.Content, + "bots": bots, + "llms_enabled": row.LlmsEnabled, + "llms_intro": row.LlmsIntro, + "production": production, + "staging": svc.Staging(), }, nil } @@ -107,7 +112,7 @@ func UpdateRobots(service func() *classes.Service) http.HandlerFunc { var blocked []string err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var err error - row, blocked, err = svc.SaveRobots(ctx, tx, classes.RobotsWrite{Content: in.Content, Bots: in.Bots}) + row, blocked, err = svc.SaveRobots(ctx, tx, classes.RobotsWrite{Content: in.Content, Bots: in.Bots, LlmsEnabled: in.LlmsEnabled, LlmsIntro: in.LlmsIntro}) return err }) if err != nil { diff --git a/controllers/api/site.go b/controllers/api/site.go new file mode 100644 index 0000000..b3e14cf --- /dev/null +++ b/controllers/api/site.go @@ -0,0 +1,29 @@ +package api + +import ( + "net/http" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/summercms/modules/wire" +) + +// Site serves GET /_seo/api/v1/site (P33-17): the site facts the frontend +// layer needs (site URL, locales, the new-tab switch, the HTML sitemap +// settings, the content types' URL patterns, the root file names and +// whether llms.txt is served). Every value is public. +func Site(service func() *classes.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + svc := service() + if svc == nil { + wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"}) + return + } + out, err := svc.Site(r.Context()) + if err != nil { + log.Error("golem15.seo: site answer", "error", err) + wire.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": errInternal}) + return + } + wire.WriteJSON(w, http.StatusOK, out) + } +} diff --git a/extensions_robots.go b/extensions_robots.go index dfc3825..919d57c 100644 --- a/extensions_robots.go +++ b/extensions_robots.go @@ -78,6 +78,30 @@ func robotsDocument() ([]byte, error) { {Key: "tab", Value: TabRobotsBots}, }}) } + // llms.txt (UI-SPEC C25) after the bot switches. + fields = append(fields, + yaml.MapItem{Key: "llms_enabled", Value: yaml.MapSlice{ + {Key: "label", Value: "golem15.seo::lang.robots.llms_enabled"}, + {Key: "comment", Value: "golem15.seo::lang.robots.llms_enabled_comment"}, + {Key: "type", Value: "switch"}, + {Key: "default", Value: false}, + {Key: "span", Value: "full"}, + {Key: "tab", Value: TabRobotsBots}, + }}, + yaml.MapItem{Key: "llms_intro", Value: yaml.MapSlice{ + {Key: "label", Value: "golem15.seo::lang.robots.llms_intro"}, + {Key: "comment", Value: "golem15.seo::lang.robots.llms_intro_comment"}, + {Key: "type", Value: "textarea"}, + {Key: "size", Value: "small"}, + {Key: "span", Value: "full"}, + {Key: "tab", Value: TabRobotsBots}, + {Key: "trigger", Value: yaml.MapSlice{ + {Key: "action", Value: "hide"}, + {Key: "field", Value: "llms_enabled"}, + {Key: "condition", Value: "unchecked"}, + }}, + }}, + ) return yaml.Marshal(yaml.MapSlice{ {Key: "tabs", Value: []tabSpec{{Name: TabRobotsBots, After: TabRobotsFile}}}, {Key: "fields", Value: fields}, @@ -103,7 +127,7 @@ func (p *Plugin) robotsForm() *pact.AdminFormExtension { if !ok || row == nil { return nil, nil } - out := map[string]any{"content": row.Content} + out := map[string]any{"content": row.Content, "llms_enabled": row.LlmsEnabled, "llms_intro": row.LlmsIntro} for _, b := range classes.Bots { out[classes.BotField(b.Token)] = row.Bots.Allowed(b.Token) } @@ -131,6 +155,14 @@ func (p *Plugin) robotsForm() *pact.AdminFormExtension { w.Bots[b.Token] = truthy(raw) } } + if raw, ok := in.Values["llms_enabled"]; ok { + on := truthy(raw) + w.LlmsEnabled = &on + } + if raw, ok := in.Values["llms_intro"]; ok { + text, _ := raw.(string) + w.LlmsIntro = &text + } _, blocked, err := svc.SaveRobots(ctx, in.DB, w) if err != nil || len(blocked) == 0 { return pact.AdminFormSaveResult{}, err diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 97ca97e..826e4a3 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -37,6 +37,10 @@ field: og_description: Social description og_image: Social image robots: + llms_enabled: Serve llms.txt + llms_enabled_comment: Lists the site's indexable pages from the sitemaps at /llms.txt, for AI tools that read it. + llms_intro: Introduction + llms_intro_comment: "A short description of the site at the top of llms.txt. Empty: the default meta description." invalid: The robots value is not valid. Use index or noindex, follow or nofollow and the advanced directives, separated by commas. inherit: Inherit site_default: Site default diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 49aacc1..64456af 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -37,6 +37,10 @@ field: og_description: Opis w mediach społecznościowych og_image: Obraz w mediach społecznościowych robots: + llms_enabled: Udostępniaj llms.txt + llms_enabled_comment: Wypisuje indeksowalne strony witryny z map witryny pod /llms.txt, dla narzędzi AI, które go czytają. + llms_intro: Wstęp + llms_intro_comment: "Krótki opis witryny na początku llms.txt. Puste: domyślny opis meta." invalid: Wartość robots jest nieprawidłowa. Użyj index lub noindex, follow lub nofollow oraz dyrektyw zaawansowanych, rozdzielonych przecinkami. inherit: Dziedzicz site_default: Domyślne diff --git a/models/robots.go b/models/robots.go index 44c1c02..970dc3c 100644 --- a/models/robots.go +++ b/models/robots.go @@ -12,11 +12,15 @@ import ( // file adds a Disallow group for every switched-off bot and the Sitemap // lines; outside production it is replaced by the non-production lock. type Robots struct { - ID uint `gorm:"column:id;primaryKey"` - Content string `gorm:"column:content"` - Bots BotSwitches `gorm:"column:bots"` - CreatedAt *time.Time `gorm:"column:created_at"` - UpdatedAt *time.Time `gorm:"column:updated_at"` + ID uint `gorm:"column:id;primaryKey"` + Content string `gorm:"column:content"` + Bots BotSwitches `gorm:"column:bots"` + // LlmsEnabled serves /llms.txt (D-25, off by default); LlmsIntro is + // its introduction, the default meta description when empty. + LlmsEnabled bool `gorm:"column:llms_enabled"` + LlmsIntro string `gorm:"column:llms_intro"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` } func (Robots) TableName() string { return "golem15_seo_robots" } diff --git a/registry_test.go b/registry_test.go index bc3e062..e7d88f1 100644 --- a/registry_test.go +++ b/registry_test.go @@ -384,3 +384,125 @@ func TestSettingsLinks(t *testing.T) { t.Fatalf("outbound = %s", raw) } } + +// TestSiteEndpoint covers GET /_seo/api/v1/site (P33-17, T-33-19): every +// key, the verification and IndexNow root files, each content type's +// pattern (not the registry's), the HTML sitemap settings, noindex. +func TestSiteEndpoint(t *testing.T) { + env := bootEnv(t, "SUMMER_GOLEM15__SEO__UNPREFIXED_LOCALE=en") + token := env.admin(t, true) + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{ + "verify_files": []map[string]any{{"filename": "google0123.html", "body": "google-site-verification: google0123.html"}}, + "links_new_tab": false, + }) + key := "0123456789abcdef0123456789abcdef" + if err := env.db.Exec(`UPDATE golem15_sitemap_settings SET indexnow_enabled = TRUE, indexnow_key = ?, html_enabled = TRUE WHERE id = 1`, key).Error; err != nil { + t.Fatal(err) + } + + rec := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil) + if rec.Header().Get("X-Robots-Tag") != "noindex" { + t.Fatalf("site headers = %v, want X-Robots-Tag noindex", rec.Header()) + } + var site map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &site); err != nil { + t.Fatal(err) + } + for _, k := range []string{"site_url", "default_locale", "locales", "unprefixed_locale", "links", "html_sitemap", "routes", "root_files", "llms"} { + if _, ok := site[k]; !ok { + t.Fatalf("site answer lacks %s: %s", k, rec.Body.String()) + } + } + if site["site_url"] != siteURL || site["default_locale"] != "en" || site["unprefixed_locale"] != "en" || site["llms"] != false { + t.Fatalf("site answer = %s", rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + `"locales":["en","pl"]`, + `"links":{"new_tab":false}`, + `"root_files":["google0123.html","` + key + `.txt"]`, + `{"type":"acme-page","pattern":"/pages/:slug"}`, + `{"type":"acme-tag","pattern":"/pages/tag/:slug"}`, + `"html_sitemap":{"enabled":true,"paths":{`, + } { + if !jsonContains(body, want) { + t.Fatalf("site answer lacks %s: %s", want, body) + } + } + if strings.Contains(body, "seo-page") || strings.Contains(body, "token") { + t.Fatalf("site answer names the registry type or a token: %s", body) + } +} + +// TestLLMSTxt covers /llms.txt (D-25, UI-SPEC C25, T-33-20): off by default +// (404), on in production it lists the indexable pages from the sitemaps +// per group, headed by the site name and the introduction; excluded +// addresses never appear; staging answers 404. +func TestLLMSTxt(t *testing.T) { + env := bootEnv(t) + token := env.admin(t, true) + env.expect(t, http.StatusNotFound, http.MethodGet, "/llms.txt", "", nil) + + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"site_name": "Acme", "default_description": "Acme makes things."}) + env.createPage(t, token, map[string]any{"title": "About [us]", "slug": "about", "published": true}) + hidden := env.createPage(t, token, map[string]any{"title": "Hidden", "slug": "hidden", "published": true}) + env.createPage(t, token, map[string]any{"title": "Draft", "slug": "draft", "published": false}) + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+hidden), token, map[string]any{ + "title": "Hidden", "slug": "hidden", "published": true, "seo_robots": map[string]any{"en": "noindex"}, + }) + env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/kontakt"}, "title": map[string]any{"en": "Contact"}}) + env.rebuildSitemap(t) + + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", token, map[string]any{"llms_enabled": true}) + rec := env.expect(t, http.StatusOK, http.MethodGet, "/llms.txt", "", nil) + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Fatalf("content type = %q", ct) + } + body := rec.Body.String() + for _, want := range []string{ + "# Acme\n\n> Acme makes things.\n", + "- [About \\[us\\]](https://app.example.test/pages/about)\n", + "## Page\n\n", + "- [Contact](https://app.example.test/kontakt)\n", + } { + if !strings.Contains(body, want) { + t.Fatalf("llms.txt lacks %q:\n%s", want, body) + } + } + if strings.Contains(body, "/pages/hidden") || strings.Contains(body, "/pages/draft") || strings.Contains(body, "/pl/") { + t.Fatalf("llms.txt lists a left-out or other-locale address:\n%s", body) + } + + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", token, map[string]any{"llms_intro": "Things, made well."}) + if body := env.expect(t, http.StatusOK, http.MethodGet, "/llms.txt", "", nil).Body.String(); !strings.Contains(body, "\n> Things, made well.\n") { + t.Fatalf("llms.txt intro:\n%s", body) + } + show := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots", token, nil).Body.String() + if !jsonContains(show, `"llms_enabled":true`) || !jsonContains(show, `"llms_intro":"Things, made well."`) { + t.Fatalf("robots answer = %s", show) + } + if site := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil).Body.String(); !jsonContains(site, `"llms":true`) { + t.Fatalf("site answer = %s", site) + } + + staging := bootEnv(t, "SUMMER_ENV=staging") + stoken := staging.admin(t, true) + staging.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", stoken, map[string]any{"llms_enabled": true}) + staging.expect(t, http.StatusNotFound, http.MethodGet, "/llms.txt", "", nil) +} + +// TestRobotsSitemapLine covers robots.txt's Sitemap line (D-22): without +// golem15.seo.robots_sitemaps it names the sitemap plugin's index only; +// with it, the configured lines. +func TestRobotsSitemapLine(t *testing.T) { + env := bootEnv(t) + body := env.expect(t, http.StatusOK, http.MethodGet, "/robots.txt", "", nil).Body.String() + if strings.Count(body, "Sitemap:") != 1 || !strings.Contains(body, "Sitemap: https://app.example.test/sitemap.xml\n") { + t.Fatalf("robots.txt without robots_sitemaps:\n%s", body) + } + custom := bootEnv(t, "SUMMER_GOLEM15__SEO__ROBOTS_SITEMAPS=/a.xml,/b.xml") + body = custom.expect(t, http.StatusOK, http.MethodGet, "/robots.txt", "", nil).Body.String() + if !strings.Contains(body, "Sitemap: "+siteURL+"/a.xml\nSitemap: "+siteURL+"/b.xml\n") || strings.Contains(body, "app.example.test") { + t.Fatalf("robots.txt with robots_sitemaps:\n%s", body) + } +} diff --git a/robots_test.go b/robots_test.go index 1aac318..e3f5461 100644 --- a/robots_test.go +++ b/robots_test.go @@ -25,6 +25,10 @@ func (e *testEnv) robotsTxt(t *testing.T) *httptest.ResponseRecorder { return rec } +// sitemapIndexURL is the sitemap plugin's index robots.txt names: the +// fixture sets no sitemap site address, so it is on app.url. +const sitemapIndexURL = "https://app.example.test/sitemap.xml" + // TestRobotsServed checks GET /robots.txt (D-22, D-25, P-23, T-32-19, // T-32-20): the stored rules, a Disallow group per switched-off AI bot and // the Sitemap line in production; the non-production lock whatever is @@ -32,7 +36,7 @@ func (e *testEnv) robotsTxt(t *testing.T) *httptest.ResponseRecorder { // store cannot be read. func TestRobotsServed(t *testing.T) { env := bootEnv(t) - if body := env.robotsTxt(t).Body.String(); body != "Sitemap: "+siteURL+"/sitemap.xml\n" { + if body := env.robotsTxt(t).Body.String(); body != "Sitemap: "+sitemapIndexURL+"\n" { t.Fatalf("fresh robots.txt = %q, want only the Sitemap line", body) } if err := env.db.Exec(`UPDATE golem15_seo_robots SET content = ?, bots = ? WHERE id = 1`, @@ -40,7 +44,7 @@ func TestRobotsServed(t *testing.T) { t.Fatal(err) } rec := env.robotsTxt(t) - want := "User-agent: *\nDisallow: /admin\n\nUser-agent: GPTBot\nDisallow: /\n\nUser-agent: CCBot\nDisallow: /\n\nSitemap: " + siteURL + "/sitemap.xml\n" + want := "User-agent: *\nDisallow: /admin\n\nUser-agent: GPTBot\nDisallow: /\n\nUser-agent: CCBot\nDisallow: /\n\nSitemap: " + sitemapIndexURL + "\n" if body := rec.Body.String(); body != want { t.Fatalf("robots.txt = %q, want %q", body, want) } @@ -128,8 +132,8 @@ func TestRobotsScreen(t *testing.T) { for _, f := range fields { names = append(names, f.Name) } - if strings.Join(names[:4], ",") != "staging,content,tester,served" || len(names) != 4+11 || names[4] != "bot_oai_searchbot" || names[8] != "bot_claude_user" { - t.Fatalf("robots form fields = %v, want staging, content, tester, served, then eleven bot switches", names) + if strings.Join(names[:4], ",") != "staging,content,tester,served" || len(names) != 4+11+2 || names[4] != "bot_oai_searchbot" || names[8] != "bot_claude_user" || strings.Join(names[15:], ",") != "llms_enabled,llms_intro" { + t.Fatalf("robots form fields = %v, want staging, content, tester, served, eleven bot switches, then the llms.txt switch and introduction", names) } env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/private/x", "match": "exact", "robots": "noindex,follow"}) @@ -163,7 +167,7 @@ func TestRobotsScreen(t *testing.T) { t.Fatalf("show = %s, want the switches", show) } served := env.expect(t, http.StatusOK, http.MethodGet, api+"/partials/robots_served?id=1", token, nil).Body.String() - if !strings.Contains(served, "User-agent: GPTBot") || !strings.Contains(served, "Sitemap: "+siteURL+"/sitemap.xml") { + if !strings.Contains(served, "User-agent: GPTBot") || !strings.Contains(served, "Sitemap: "+sitemapIndexURL) { t.Fatalf("served partial = %s, want the composed file", served) } diff --git a/routes.go b/routes.go index f2498b0..b2a51ff 100644 --- a/routes.go +++ b/routes.go @@ -22,7 +22,7 @@ const ( BucketAPI = "seo-api" ) -// Routes mounts GET /robots.txt at the site root, the public /_seo/api/v1 +// Routes mounts GET /robots.txt and GET /llms.txt at the site root, the public /_seo/api/v1 // endpoints and the management API. The public ones carry no backend // middleware: crawlers and the frontend server call them anonymously (the // 404 report with its own token). The management routes (bucket seo-api) @@ -31,8 +31,10 @@ const ( // tool (mcp.go). Every /_seo/api/v1 answer carries X-Robots-Tag: noindex. func (p *Plugin) Routes(r pact.Router) error { r.Get("/robots.txt", apicontrollers.Robots(p.Service), "throttle:"+BucketRobots) + r.Get("/llms.txt", apicontrollers.LLMS(p.Service), "throttle:"+BucketRobots) r.Group("/_seo/api/v1", nil, func(g pact.Router) { g.Get("/resolve", noindex(apicontrollers.Resolve(p.Service)), "throttle:"+BucketResolve) + g.Get("/site", noindex(apicontrollers.Site(p.Service)), "throttle:"+BucketResolve) g.Post("/notfound", noindex(apicontrollers.NotFound(p.Service)), "throttle:"+BucketNotFound) // The frontend server's route report (D-33), token-authenticated // like the 404 report.