commit 89383efba9faff5db72cfb37636b84952dfd140a from: ale date: Sun Aug 30 01:08:01 2026 UTC Order references alphabetically Process now numbers cited entries 1..N in alphabetical order of the reference (first author's family name, then year, then title; entries with no author sort by title) and lists the References slide in that order -- IEEE-numbered with alphabetical ordering. In-text [n] markers therefore need not appear in ascending order. Replaces first-appearance numbering. 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) } }