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).
206 lines
5.8 KiB
Go
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 + "/"
|
|
}
|