// Package classes generates the sitemap: it walks the definition items, // resolves registry items through the framework's menu item type registry // and writes the PHP-identical XML. package classes import ( "context" "time" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/festival" "git.golem15.com/golem15/summercms/modules/pact" ) // MaxURLs is the sitemap protocol's URL limit (PHP Definition::MAX_URLS): // the 50000th URL is written, the 50001st is not. const MaxURLs = 50000 // lastmodLayout is PHP date('c') in UTC: "+00:00", never "Z". const lastmodLayout = "2006-01-02T15:04:05-07:00" // Resolver resolves a registry item; *signpost.Registry implements it. type Resolver interface { Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error) } // Generator writes the sitemap of a definition, following PHP // Definition::generateSitemap and addItemToSet. // // Root is the backend root URL (scheme and host without a trailing slash) // that Url::to builds absolute URLs from. Resolver resolves registry items; // nil skips them. Settings are the sitemap settings (tiers, priorities, // frontend default locale, manifest path), Tiers the compiled tier rules, // and Events the bus BeforeAddItem and AddItem are fired on (nil fires // nothing). Manifest, when set, is used instead of reading the settings' // updates_manifest_path. type Generator struct { Root string Resolver Resolver Settings models.Settings Tiers Tiers Events *festival.Bus Manifest Manifest manifestLoaded bool entries []URLEntry count int } // itemInfo is PHP's $itemInfo: the URL (set marks it present), the record's // modification time and the hreflang alternates. type itemInfo struct { url string set bool mtime *time.Time alternates []Alternate } // Generate returns the sitemap XML of items, or "" when there are none (PHP // generateSitemap). A url item is added with its URL. A registry item is // resolved with its URL pattern, its nesting and the settings' frontend // default locale as the unprefixed locale; its URL is added when it has one, // then its sub-items: when the item has no URL of its own (a list type), // each sub-item is added once per alternate, with that alternate's URL as // loc and every alternate as hreflang, as the PHP list types returned them; // otherwise each sub-item is added once with its own URL. A type the // registry does not know, or a Resolve that finds nothing, adds nothing. // // Generate does not apply the frontend_url rewrite; the route does // (Rewrite). func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem) (string, error) { if len(items) == 0 { return "", nil } if ctx == nil { ctx = context.Background() } g.entries, g.count = nil, 0 for i := range items { item := &items[i] if item.Type == models.ItemTypeURL { if err := g.addItem(ctx, item, itemInfo{}); err != nil { return "", err } continue } resolved, err := g.resolve(ctx, item) if err != nil { return "", err } if resolved == nil { continue } if resolved.URL != "" { if err := g.addItem(ctx, item, infoOf(*resolved)); err != nil { return "", err } if err := g.addChildren(ctx, item, resolved.Items); err != nil { return "", err } continue } if err := g.addPerLocale(ctx, item, resolved.Items); err != nil { return "", err } } return Serialize(g.Stylesheet(), g.entries), nil } // Stylesheet is the stylesheet href PHP built: // Url::buildUrl(Url::current(), ['path' => 'sitemap.xsl']). func (g *Generator) Stylesheet() string { return replacePath(URLTo(g.Root, "/sitemap.xml"), "sitemap.xsl") } func (g *Generator) resolve(ctx context.Context, item *models.DefinitionItem) (*pact.ResolvedMenuItem, error) { if g.Resolver == nil || item.Type == "" { return nil, nil } if reg, ok := g.Resolver.(interface { Type(string) (pact.MenuItemType, bool) }); ok { if _, known := reg.Type(item.Type); !known { return nil, nil } } return g.Resolver.Resolve(ctx, item.Type, models.Text(item.Reference), models.Text(item.URLPattern), item.Nesting, models.Text(g.Settings.FrontendDefaultLocale)) } // addChildren adds every child with a URL, then its own children. func (g *Generator) addChildren(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error { for _, child := range children { if child.URL != "" { if err := g.addItem(ctx, item, infoOf(child)); err != nil { return err } } if err := g.addChildren(ctx, item, child.Items); err != nil { return err } } return nil } // addPerLocale adds the children of a list type: each once per alternate // (once with its URL when it has no alternates), then its own children. func (g *Generator) addPerLocale(ctx context.Context, item *models.DefinitionItem, children []pact.ResolvedMenuItem) error { for _, child := range children { info := infoOf(child) if len(child.Alternates) == 0 { if child.URL != "" { if err := g.addItem(ctx, item, info); err != nil { return err } } if err := g.addChildren(ctx, item, child.Items); err != nil { return err } continue } for _, alt := range child.Alternates { perLocale := info perLocale.url = alt.URL if perLocale.url != "" { if err := g.addItem(ctx, item, perLocale); err != nil { return err } } if err := g.addChildren(ctx, item, child.Items); err != nil { return err } } } return nil } func infoOf(r pact.ResolvedMenuItem) itemInfo { info := itemInfo{url: r.URL, set: true, mtime: r.MTime} for _, a := range r.Alternates { info.alternates = append(info.alternates, Alternate{Hreflang: a.Locale, Href: a.URL}) } return info } func (g *Generator) manifest() Manifest { if !g.manifestLoaded { if g.Manifest == nil { g.Manifest = LoadManifest(models.Text(g.Settings.UpdatesManifestPath)) } g.manifestLoaded = true } return g.Manifest } // addItem is PHP Definition::addItemToSet, in its order: the URL cap, a // missing URL filled through Url::to, lastmod from the record or the // manifest, BeforeAddItem, an empty URL dropped and the URL passed through // Url::to again, the count, the tier or item priority, the elements and // AddItem. Unlike PHP, a URL or alternate that does not come out as an // http or https URL is dropped. func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, info itemInfo) error { if g.count >= MaxURLs { return nil } if !info.set { info.url = URLTo(g.Root, models.Text(item.URL)) } var lastmod time.Time hasLastmod := false if info.mtime != nil { lastmod, hasLastmod = *info.mtime, true } else if t, ok := g.manifest().Lastmod(info.url); ok { lastmod, hasLastmod = t, true } lastmodText := "" if hasLastmod && lastmod.Unix() != 0 { lastmodText = lastmod.UTC().Format(lastmodLayout) } if g.Events != nil { before := &BeforeAddItem{Item: *item, Info: ItemInfo{URL: info.url, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), info.alternates...)}} skipped, err := g.Events.UntilHandled(ctx, before) if err != nil { return err } if skipped { return nil } } if info.url == "" || info.url == "0" { return nil } loc := URLTo(g.Root, info.url) if !httpURL(loc) { // Only http and https URLs are written: a javascript: or other // scheme that an import, a registry type or a pattern let through // would be a clickable link in sitemap.xsl. return nil } g.count++ priority, ok := g.Tiers.Priority(loc, g.Settings) if !ok { priority = models.Text(item.Priority) } entry := URLEntry{ Loc: loc, Lastmod: lastmodText, Changefreq: models.Text(item.Changefreq), Priority: priority, } if len(info.alternates) > 1 { for _, a := range info.alternates { href := URLTo(g.Root, a.Href) if !httpURL(href) { continue } entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: href}) } } if g.Events != nil { added := &AddItem{Item: *item, Info: ItemInfo{URL: loc, MTime: info.mtime, Lastmod: lastmodText, Alternates: append([]Alternate(nil), entry.Alternates...)}} if err := g.Events.Fire(ctx, added); err != nil { return err } entry.Extra = added.Extra } g.entries = append(g.entries, entry) return nil }