Files
hotdog-cms/internal/site/taxonomy.go
T
jcoffey-dev a8f0227a58 What the jcoffey.dev port needed: pagination, tags, redirects, RSS, search
Pagination for lists and tag pages; tag spellings merged regardless of hyphens and #, labelled by the spelling used most, with term_index_min for thin pages; redirects as fallback pages plus 301 rules in container images; RSS 2.0 feeds; markdown autolink, external_links: new_tab and trust_html; reading time and summaries computed from the Markdown; a page's lead image for its social card; per-collection search indexes; and template helpers (excerpt, termURL, formatDate, fileHash, imageSize, pageAt, concat, cond, email_off markers).
2026-10-10 13:42:46 -07:00

206 lines
5.8 KiB
Go

package site
import (
"sort"
"strings"
"unicode"
)
// Taxonomy terms are matched and addressed by a slug, and shown the way
// people spelled them. Spellings that differ only in case, a leading #, or
// hyphens are one subject: "open source", "#OpenSource" and "open-source" share
// a page, named for whichever spelling is used most.
// TermSlug makes a term's URL segment: lower case, no leading #, spaces and
// underscores become hyphens, other punctuation goes ("node.js" -> "nodejs").
// Letters in any script are kept.
func TermSlug(v string) string {
s := strings.TrimLeft(strings.ToLower(strings.TrimSpace(v)), "#")
var b strings.Builder
dash := false
for _, r := range s {
switch {
case unicode.IsSpace(r) || r == '_' || r == '-':
if !dash {
b.WriteByte('-')
dash = true
}
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
dash = false
}
}
return strings.Trim(b.String(), "-")
}
// TermInfo is one term of a taxonomy.
type TermInfo struct {
Slug string
Label string
Count int // how many times it is used across pages
URL string
Page *Page
}
type termIndex struct {
canon map[string]string // hyphen-blind key -> slug used most
infos map[string]*TermInfo
order []string // by count, then first appearance
byCount []TermInfo
byLabel []TermInfo
}
func hyphenBlind(slug string) string { return strings.ReplaceAll(slug, "-", "") }
// mostUsed returns the key with the highest count; ties go to the one seen first.
func mostUsed(counts map[string]int, seen []string) string {
best, n := "", -1
for _, k := range seen {
if counts[k] > n {
best, n = k, counts[k]
}
}
return best
}
func (s *Site) assembleTaxonomies() {
cfg := s.Config
s.terms = map[string]*termIndex{}
// Newest first, so ties between spellings go to the more recent writing.
pages := make([]*Page, 0, len(s.Pages))
for _, p := range s.Pages {
if p.Kind == KindPage {
pages = append(pages, p)
}
}
sortPages(pages, "date")
for _, tax := range cfg.Taxonomies {
// Pass 1: which slug each hyphen-blind key settles on.
slugCounts := map[string]map[string]int{}
slugSeen := map[string][]string{}
for _, p := range pages {
for _, v := range strList(p.Params[tax]) {
slug := TermSlug(v)
if slug == "" {
continue
}
k := hyphenBlind(slug)
if slugCounts[k] == nil {
slugCounts[k] = map[string]int{}
}
if slugCounts[k][slug] == 0 {
slugSeen[k] = append(slugSeen[k], slug)
}
slugCounts[k][slug]++
}
}
ti := &termIndex{canon: map[string]string{}, infos: map[string]*TermInfo{}}
for k := range slugCounts {
ti.canon[k] = mostUsed(slugCounts[k], slugSeen[k])
}
// Pass 2: counts, labels and the pages under each term.
labelCounts := map[string]map[string]int{}
labelSeen := map[string][]string{}
termPages := map[string][]*Page{}
for _, p := range pages {
onPage := map[string]bool{}
for _, v := range strList(p.Params[tax]) {
slug := ti.Slug(v)
if slug == "" {
continue
}
info := ti.infos[slug]
if info == nil {
info = &TermInfo{Slug: slug, URL: "/" + tax + "/" + slug + "/"}
ti.infos[slug] = info
ti.order = append(ti.order, slug)
labelCounts[slug] = map[string]int{}
}
info.Count++
label := strings.TrimLeft(strings.TrimSpace(v), "#")
if labelCounts[slug][label] == 0 {
labelSeen[slug] = append(labelSeen[slug], label)
}
labelCounts[slug][label]++
if !onPage[slug] {
onPage[slug] = true
p.Terms[tax] = append(p.Terms[tax], slug)
termPages[slug] = append(termPages[slug], p)
}
}
}
sort.SliceStable(ti.order, func(i, j int) bool { return ti.infos[ti.order[i]].Count > ti.infos[ti.order[j]].Count })
index := &Page{Kind: KindTerms, Title: titleCase(tax), Path: "/" + tax + "/", Taxonomy: tax, Term: tax,
Params: map[string]any{}, Terms: map[string][]string{}, Site: s}
index.Permalink = cfg.URL + index.Path
for _, slug := range ti.order {
info := ti.infos[slug]
info.Label = mostUsed(labelCounts[slug], labelSeen[slug])
tp := &Page{Kind: KindTerm, Title: info.Label, Path: info.URL, Taxonomy: tax, Term: slug,
Pages: termPages[slug], Params: map[string]any{}, Terms: map[string][]string{}, Site: s}
tp.Permalink = cfg.URL + tp.Path
tp.NoIndex = cfg.TermIndexMin > 0 && len(tp.Pages) < cfg.TermIndexMin
info.Page = tp
s.Pages = append(s.Pages, tp)
index.Pages = append(index.Pages, tp)
}
s.terms[tax] = ti
s.Pages = append(s.Pages, index)
}
}
// Slug returns the page-sharing slug for a spelling, or "" if it has none.
func (ti *termIndex) Slug(v string) string {
slug := TermSlug(v)
if slug == "" {
return ""
}
if c, ok := ti.canon[hyphenBlind(slug)]; ok {
return c
}
return slug
}
// Terms lists a taxonomy's terms, most used first. The list is shared by
// every page of a build; don't change it.
func (s *Site) Terms(tax string) []TermInfo {
ti := s.terms[tax]
if ti == nil {
return nil
}
if ti.byCount == nil {
ti.byCount = make([]TermInfo, 0, len(ti.order))
for _, slug := range ti.order {
ti.byCount = append(ti.byCount, *ti.infos[slug])
}
}
return ti.byCount
}
// TermsByLabel lists a taxonomy's terms in label order.
func (s *Site) TermsByLabel(tax string) []TermInfo {
ti := s.terms[tax]
if ti == nil {
return nil
}
if ti.byLabel == nil {
ti.byLabel = append([]TermInfo{}, s.Terms(tax)...)
sort.SliceStable(ti.byLabel, func(i, j int) bool { return ti.byLabel[i].Label < ti.byLabel[j].Label })
}
return ti.byLabel
}
// TermURL is the address of the page a spelling of a term belongs to, or ""
// if the spelling has no letters or digits to make an address of.
func (s *Site) TermURL(tax, v string) string {
ti := s.terms[tax]
if ti == nil {
return ""
}
slug := ti.Slug(v)
if slug == "" {
return ""
}
return "/" + tax + "/" + slug + "/"
}