Commit Diff


commit - 84954bcd2e666a66a8ab006b3311573b65575a17
commit + 89383efba9faff5db72cfb37636b84952dfd140a
blob - 2fd121479cbecb4be3d91cad9ff1b7e712aff070
blob + 705409775e17120e3823662e63b2967e508970e5
--- internal/bib/cite.go
+++ internal/bib/cite.go
@@ -3,37 +3,48 @@ package bib
 import (
 	"fmt"
 	"regexp"
+	"sort"
 	"strings"
 )
 
 var citeRe = regexp.MustCompile(`\[@([A-Za-z0-9_:.-]+)\]`)
 
-// Process scans slides in order for [@key] citations, numbers them by
-// first appearance, rewrites each [@key] into a [n] markdown link to a
-// #key anchor (it never resolves anywhere -- it's just how glamour picks
-// up the theme's link_text color without hardcoding one), and appends a
-// References slide listing every cited entry in citation-number order.
-// Citation keys not found in entries are left as literal [@key] text.
+// Process scans slides for [@key] citations, numbers the cited entries
+// 1..N in alphabetical order of the reference (first author's family
+// name, then year, then title), rewrites each [@key] into a [n] markdown
+// link to a #key anchor (it never resolves anywhere -- it's just how
+// glamour picks up the theme's link_text color without hardcoding one),
+// and appends a References slide listing every cited entry in that same
+// alphabetical order. Citation keys not found in entries are left as
+// literal [@key] text.
 func Process(slides []string, entries map[string]Entry) []string {
-	numbers := make(map[string]int)
-	var order []string
-
+	seen := make(map[string]bool)
+	var cited []string
 	for _, slide := range slides {
 		for _, m := range citeRe.FindAllStringSubmatch(slide, -1) {
 			key := m[1]
 			if _, ok := entries[key]; !ok {
 				continue
 			}
-			if _, seen := numbers[key]; !seen {
-				numbers[key] = len(order) + 1
-				order = append(order, key)
+			if !seen[key] {
+				seen[key] = true
+				cited = append(cited, key)
 			}
 		}
 	}
-	if len(order) == 0 {
+	if len(cited) == 0 {
 		return slides
 	}
 
+	order := append([]string(nil), cited...)
+	sort.SliceStable(order, func(i, j int) bool {
+		return sortKey(entries[order[i]]) < sortKey(entries[order[j]])
+	})
+	numbers := make(map[string]int, len(order))
+	for i, key := range order {
+		numbers[key] = i + 1
+	}
+
 	out := make([]string, len(slides))
 	for i, slide := range slides {
 		out[i] = citeRe.ReplaceAllStringFunc(slide, func(match string) string {
@@ -49,6 +60,24 @@ func Process(slides []string, entries map[string]Entry
 	return out
 }
 
+// sortKey is the alphabetical sort key for an entry: the first author's
+// family name (lowercased), then year, then title, with NUL separators so
+// shorter names sort before longer ones sharing a prefix. Entries with no
+// author fall back to their title.
+func sortKey(e Entry) string {
+	f := e.Fields
+	var name string
+	if as := splitAuthors(f["author"]); len(as) > 0 {
+		if name = strings.ToLower(as[0].Last); name == "" {
+			name = strings.ToLower(as[0].First)
+		}
+	}
+	if name == "" {
+		name = strings.ToLower(strings.TrimLeft(f["title"], `"'`))
+	}
+	return name + "\x00" + f["year"] + "\x00" + strings.ToLower(f["title"])
+}
+
 func referencesSlide(order []string, entries map[string]Entry) string {
 	var b strings.Builder
 	b.WriteString("## References\n\n")
blob - 859c5d3d3cc5bd63696febf93c6c1ac7d6b6689d
blob + 01ca38f1eddfacd2a801214f74f8f27806ea6985
--- internal/bib/cite_test.go
+++ internal/bib/cite_test.go
@@ -16,11 +16,11 @@ func testEntries() map[string]Entry {
 	}
 }
 
-func TestProcess_NumbersByFirstAppearance(t *testing.T) {
+func TestProcess_NumbersAlphabetically(t *testing.T) {
 	slides := []string{
 		"intro slide, no citations",
-		"see [@doe2019] and also [@smith2020]",
-		"cited again: [@doe2019]",
+		"see [@smith2020] and also [@doe2019]", // Smith cited first
+		"cited again: [@smith2020]",
 	}
 	out := Process(slides, testEntries())
 
@@ -30,22 +30,24 @@ func TestProcess_NumbersByFirstAppearance(t *testing.T
 	if out[0] != slides[0] {
 		t.Errorf("slide 0 should be untouched, got %q", out[0])
 	}
-	if !strings.Contains(out[1], "[[1](#doe2019)]") || !strings.Contains(out[1], "[[2](#smith2020)]") {
-		t.Errorf("slide 1 = %q, want [[1]] (doe2019, first seen) and [[2]] (smith2020)", out[1])
+	// Doe sorts before Smith, so Doe is [1] and Smith is [2] even though
+	// Smith is cited first.
+	if !strings.Contains(out[1], "[[2](#smith2020)]") || !strings.Contains(out[1], "[[1](#doe2019)]") {
+		t.Errorf("slide 1 = %q, want smith=[[2]] doe=[[1]] (alphabetical)", out[1])
 	}
-	if !strings.Contains(out[2], "[[1](#doe2019)]") {
-		t.Errorf("slide 2 = %q, want repeated citation to reuse [[1]]", out[2])
+	if !strings.Contains(out[2], "[[2](#smith2020)]") {
+		t.Errorf("slide 2 = %q, want repeated citation to reuse [[2]]", out[2])
 	}
 
 	refs := out[len(out)-1]
 	if !strings.HasPrefix(refs, "## References") {
 		t.Errorf("references slide should start with heading, got %q", refs)
 	}
-	if !strings.Contains(refs, "[1] J. Doe") {
-		t.Errorf("references slide missing entry 1 (doe2019): %q", refs)
+	if !strings.Contains(refs, "[1] J. Doe") || !strings.Contains(refs, "[2] J. Smith") {
+		t.Errorf("references not numbered alphabetically: %q", refs)
 	}
-	if !strings.Contains(refs, "[2] J. Smith") {
-		t.Errorf("references slide missing entry 2 (smith2020): %q", refs)
+	if strings.Index(refs, "[1] J. Doe") > strings.Index(refs, "[2] J. Smith") {
+		t.Errorf("references slide should list [1] before [2]: %q", refs)
 	}
 }