Files
sm-seo-plugin/classes/site.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

140 lines
4.1 KiB
Go

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)
}