Commit Diff


commit - a31d8fbf9d5ffda70750701ee27ed51bdbac593a
commit + 372f60546819d91aaf4368cb7ea2b8a980156bfc
blob - /dev/null
blob + 0dbf4342c0d9875e78206fd4ffe12edd84c9b3b6 (mode 644)
--- /dev/null
+++ internal/bib/accent.go
@@ -0,0 +1,132 @@
+package bib
+
+import "strings"
+
+// Accent tables covering the accented Latin letters most likely to appear
+// in author names and titles. Not exhaustive.
+var (
+	acute = map[rune]rune{
+		'a': 'á', 'e': 'é', 'i': 'í', 'o': 'ó', 'u': 'ú', 'y': 'ý',
+		'A': 'Á', 'E': 'É', 'I': 'Í', 'O': 'Ó', 'U': 'Ú', 'Y': 'Ý',
+		'c': 'ć', 'n': 'ń', 's': 'ś', 'z': 'ź',
+		'C': 'Ć', 'N': 'Ń', 'S': 'Ś', 'Z': 'Ź',
+	}
+	grave = map[rune]rune{
+		'a': 'à', 'e': 'è', 'i': 'ì', 'o': 'ò', 'u': 'ù',
+		'A': 'À', 'E': 'È', 'I': 'Ì', 'O': 'Ò', 'U': 'Ù',
+	}
+	umlaut = map[rune]rune{
+		'a': 'ä', 'e': 'ë', 'i': 'ï', 'o': 'ö', 'u': 'ü', 'y': 'ÿ',
+		'A': 'Ä', 'E': 'Ë', 'I': 'Ï', 'O': 'Ö', 'U': 'Ü',
+	}
+	tilde = map[rune]rune{
+		'a': 'ã', 'n': 'ñ', 'o': 'õ',
+		'A': 'Ã', 'N': 'Ñ', 'O': 'Õ',
+	}
+	circumflex = map[rune]rune{
+		'a': 'â', 'e': 'ê', 'i': 'î', 'o': 'ô', 'u': 'û',
+		'A': 'Â', 'E': 'Ê', 'I': 'Î', 'O': 'Ô', 'U': 'Û',
+	}
+	cedilla = map[rune]rune{'c': 'ç', 'C': 'Ç', 's': 'ş', 'S': 'Ş'}
+	caron   = map[rune]rune{
+		'c': 'č', 's': 'š', 'z': 'ž', 'e': 'ě', 'r': 'ř',
+		'C': 'Č', 'S': 'Š', 'Z': 'Ž',
+	}
+)
+
+func accentTable(cmd rune) map[rune]rune {
+	switch cmd {
+	case '\'':
+		return acute
+	case '`':
+		return grave
+	case '"':
+		return umlaut
+	case '~':
+		return tilde
+	case '^':
+		return circumflex
+	case 'c':
+		return cedilla
+	case 'v':
+		return caron
+	}
+	return nil
+}
+
+// stripAccents converts recognized LaTeX accent commands (\'e, \"{o},
+// \c{c}, ...) to their precomposed Unicode letter, and drops any
+// remaining brace characters (typographic protection, e.g. {IEEE}, with
+// no textual meaning of their own). Unrecognized backslash commands are
+// left as-is.
+func stripAccents(s string) string {
+	var out strings.Builder
+	runes := []rune(s)
+	i := 0
+	for i < len(runes) {
+		switch runes[i] {
+		case '\\':
+			i = writeAccent(&out, runes, i)
+		case '{', '}':
+			i++
+		default:
+			out.WriteRune(runes[i])
+			i++
+		}
+	}
+	return out.String()
+}
+
+func writeAccent(out *strings.Builder, runes []rune, i int) int {
+	i++ // skip backslash
+	if i >= len(runes) {
+		out.WriteRune('\\')
+		return i
+	}
+	cmd := runes[i]
+	i++
+
+	table := accentTable(cmd)
+	if table == nil {
+		// Not a command we recognize: leave it untouched, including
+		// whatever follows, so we never eat a space that wasn't ours.
+		out.WriteRune('\\')
+		out.WriteRune(cmd)
+		return i
+	}
+
+	// Letter-based commands (\c, \v, ...) need a space or braces to
+	// separate them from the base letter, per TeX control-word lexing;
+	// that separating space carries no output of its own -- but if the
+	// base letter turns out unmapped, restore it in the fallback below.
+	spacedCmd := i < len(runes) && runes[i] == ' '
+	if spacedCmd {
+		i++
+	}
+	braced := i < len(runes) && runes[i] == '{'
+	if braced {
+		i++
+	}
+	if i >= len(runes) {
+		out.WriteRune('\\')
+		out.WriteRune(cmd)
+		return i
+	}
+
+	base := runes[i]
+	if r, ok := table[base]; ok {
+		out.WriteRune(r)
+		i++
+		if braced && i < len(runes) && runes[i] == '}' {
+			i++
+		}
+		return i
+	}
+
+	out.WriteRune('\\')
+	out.WriteRune(cmd)
+	if spacedCmd {
+		out.WriteRune(' ')
+	}
+	return i
+}
blob - /dev/null
blob + fa6dcfedf6e3bee608db4b43a5e94f2d16bdcbdd (mode 644)
--- /dev/null
+++ internal/bib/author.go
@@ -0,0 +1,117 @@
+package bib
+
+import "strings"
+
+// Author is a single name split into given (First) and family (Last)
+// parts. Others marks the "and others" sentinel in a BibTeX author field.
+type Author struct {
+	First, Last string
+	Others      bool
+}
+
+// splitAuthors parses a BibTeX author/editor field ("First Last and
+// First2 Last2 and others"). It handles the two common per-author forms:
+// "Last, First" (comma present) and "First Last" (no comma, last word is
+// the family name). It doesn't handle "von"/"Jr" particles.
+func splitAuthors(field string) []Author {
+	if field == "" {
+		return nil
+	}
+	parts := strings.Split(field, " and ")
+	authors := make([]Author, 0, len(parts))
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if p == "" {
+			continue
+		}
+		if strings.EqualFold(p, "others") {
+			authors = append(authors, Author{Others: true})
+			continue
+		}
+		authors = append(authors, splitAuthor(p))
+	}
+	return authors
+}
+
+func splitAuthor(s string) Author {
+	if comma := strings.Index(s, ","); comma >= 0 {
+		return Author{
+			Last:  strings.TrimSpace(s[:comma]),
+			First: strings.TrimSpace(s[comma+1:]),
+		}
+	}
+	words := strings.Fields(s)
+	switch len(words) {
+	case 0:
+		return Author{}
+	case 1:
+		return Author{Last: words[0]}
+	default:
+		return Author{
+			First: strings.Join(words[:len(words)-1], " "),
+			Last:  words[len(words)-1],
+		}
+	}
+}
+
+// formatAuthors renders authors in numeric/IEEE-ish style: one author is
+// just "F. Last"; two are joined with "and"; three to six are a comma
+// list with "and" before the last; more than six (or a trailing "and
+// others") collapses to the first author plus "et al."
+func formatAuthors(authors []Author) string {
+	if len(authors) == 0 {
+		return ""
+	}
+
+	others := false
+	if authors[len(authors)-1].Others {
+		others = true
+		authors = authors[:len(authors)-1]
+	}
+	if len(authors) == 0 {
+		return ""
+	}
+	if len(authors) > 6 {
+		others = true
+		authors = authors[:1]
+	}
+
+	formatted := make([]string, len(authors))
+	for i, a := range authors {
+		formatted[i] = formatOneAuthor(a)
+	}
+
+	if others {
+		return formatted[0] + " et al."
+	}
+
+	switch len(formatted) {
+	case 1:
+		return formatted[0]
+	case 2:
+		return formatted[0] + " and " + formatted[1]
+	default:
+		return strings.Join(formatted[:len(formatted)-1], ", ") + ", and " + formatted[len(formatted)-1]
+	}
+}
+
+// formatOneAuthor renders a single author as initials + last name, e.g.
+// First "Given Names" -> "G. N. Last".
+func formatOneAuthor(a Author) string {
+	var initials []string
+	for _, w := range strings.Fields(a.First) {
+		r := []rune(w)
+		if len(r) == 0 {
+			continue
+		}
+		initials = append(initials, string(r[0])+".")
+	}
+	switch {
+	case a.Last == "":
+		return strings.Join(initials, " ")
+	case len(initials) == 0:
+		return a.Last
+	default:
+		return strings.Join(initials, " ") + " " + a.Last
+	}
+}
blob - /dev/null
blob + 03e1b44a874d322ba1389c6723c03f2337a7e737 (mode 644)
--- /dev/null
+++ internal/bib/author_test.go
@@ -0,0 +1,91 @@
+package bib
+
+import "testing"
+
+func TestSplitAuthors(t *testing.T) {
+	tests := []struct {
+		name  string
+		field string
+		want  []Author
+	}{
+		{"empty", "", nil},
+		{"single last-first", "Smith, John", []Author{{First: "John", Last: "Smith"}}},
+		{"single first-last", "John Smith", []Author{{First: "John", Last: "Smith"}}},
+		{
+			"two authors mixed forms",
+			"Smith, John and Jane Doe",
+			[]Author{{First: "John", Last: "Smith"}, {First: "Jane", Last: "Doe"}},
+		},
+		{
+			"and others",
+			"Smith, John and others",
+			[]Author{{First: "John", Last: "Smith"}, {Others: true}},
+		},
+		{"single word", "Cher", []Author{{Last: "Cher"}}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := splitAuthors(tt.field)
+			if !authorsEqual(got, tt.want) {
+				t.Errorf("splitAuthors(%q) = %+v, want %+v", tt.field, got, tt.want)
+			}
+		})
+	}
+}
+
+func authorsEqual(a, b []Author) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
+
+func TestFormatAuthors(t *testing.T) {
+	tests := []struct {
+		name    string
+		authors []Author
+		want    string
+	}{
+		{"none", nil, ""},
+		{"one", []Author{{First: "John", Last: "Smith"}}, "J. Smith"},
+		{
+			"two",
+			[]Author{{First: "John", Last: "Smith"}, {First: "Jane", Last: "Doe"}},
+			"J. Smith and J. Doe",
+		},
+		{
+			"three",
+			[]Author{{First: "A", Last: "One"}, {First: "B", Last: "Two"}, {First: "C", Last: "Three"}},
+			"A. One, B. Two, and C. Three",
+		},
+		{
+			"and others",
+			[]Author{{First: "John", Last: "Smith"}, {Others: true}},
+			"J. Smith et al.",
+		},
+		{
+			"more than six collapses to et al.",
+			[]Author{
+				{First: "A", Last: "One"}, {First: "B", Last: "Two"}, {First: "C", Last: "Three"},
+				{First: "D", Last: "Four"}, {First: "E", Last: "Five"}, {First: "F", Last: "Six"},
+				{First: "G", Last: "Seven"},
+			},
+			"A. One et al.",
+		},
+		{"multi-word first name", []Author{{First: "John Michael", Last: "Smith"}}, "J. M. Smith"},
+		{"no first name", []Author{{Last: "Cher"}}, "Cher"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := formatAuthors(tt.authors)
+			if got != tt.want {
+				t.Errorf("formatAuthors(%+v) = %q, want %q", tt.authors, got, tt.want)
+			}
+		})
+	}
+}
blob - /dev/null
blob + ab4242d360f3c856a526cb5e59ad618c46cfe834 (mode 644)
--- /dev/null
+++ internal/bib/cite.go
@@ -0,0 +1,63 @@
+package bib
+
+import (
+	"fmt"
+	"regexp"
+	"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] styled with the
+// theme's link_text color (a real markdown link to a #key anchor -- it
+// never resolves to anywhere, it's just how glamour picks up the color,
+// same trick as everything else in this fork that wants themed styling
+// without hardcoding a color), 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, a visible signal that
+// something's missing rather than a silent drop.
+func Process(slides []string, entries map[string]Entry) []string {
+	numbers := make(map[string]int)
+	var order []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 len(order) == 0 {
+		return slides
+	}
+
+	out := make([]string, len(slides))
+	for i, slide := range slides {
+		out[i] = citeRe.ReplaceAllStringFunc(slide, func(match string) string {
+			key := citeRe.FindStringSubmatch(match)[1]
+			n, ok := numbers[key]
+			if !ok {
+				return match
+			}
+			return fmt.Sprintf("[[%d](#%s)]", n, key)
+		})
+	}
+	out = append(out, referencesSlide(order, entries))
+	return out
+}
+
+func referencesSlide(order []string, entries map[string]Entry) string {
+	var b strings.Builder
+	b.WriteString("## References\n\n")
+	for i, key := range order {
+		b.WriteString(FormatReference(i+1, entries[key]))
+		b.WriteString("\n\n")
+	}
+	return strings.TrimRight(b.String(), "\n")
+}
blob - /dev/null
blob + 859c5d3d3cc5bd63696febf93c6c1ac7d6b6689d (mode 644)
--- /dev/null
+++ internal/bib/cite_test.go
@@ -0,0 +1,70 @@
+package bib
+
+import (
+	"strings"
+	"testing"
+)
+
+func testEntries() map[string]Entry {
+	return map[string]Entry{
+		"smith2020": {Type: "article", Key: "smith2020", Fields: map[string]string{
+			"author": "Smith, John", "title": "First Paper", "year": "2020",
+		}},
+		"doe2019": {Type: "article", Key: "doe2019", Fields: map[string]string{
+			"author": "Doe, Jane", "title": "Second Paper", "year": "2019",
+		}},
+	}
+}
+
+func TestProcess_NumbersByFirstAppearance(t *testing.T) {
+	slides := []string{
+		"intro slide, no citations",
+		"see [@doe2019] and also [@smith2020]",
+		"cited again: [@doe2019]",
+	}
+	out := Process(slides, testEntries())
+
+	if len(out) != len(slides)+1 {
+		t.Fatalf("got %d slides, want %d (+1 for references)", len(out), len(slides)+1)
+	}
+	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])
+	}
+	if !strings.Contains(out[2], "[[1](#doe2019)]") {
+		t.Errorf("slide 2 = %q, want repeated citation to reuse [[1]]", 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, "[2] J. Smith") {
+		t.Errorf("references slide missing entry 2 (smith2020): %q", refs)
+	}
+}
+
+func TestProcess_UnknownKeyLeftLiteral(t *testing.T) {
+	slides := []string{"cites [@missing] key"}
+	out := Process(slides, testEntries())
+
+	if len(out) != 1 {
+		t.Fatalf("no known citations, should not append a references slide; got %d slides", len(out))
+	}
+	if out[0] != slides[0] {
+		t.Errorf("unresolved citation should be left untouched, got %q", out[0])
+	}
+}
+
+func TestProcess_NoCitations(t *testing.T) {
+	slides := []string{"just text", "more text"}
+	out := Process(slides, testEntries())
+	if len(out) != len(slides) {
+		t.Fatalf("no citations found, slide count should be unchanged")
+	}
+}
blob - /dev/null
blob + 2a1f18b457d79ce63ec1a01f09d7de065a5c379a (mode 644)
--- /dev/null
+++ internal/bib/format.go
@@ -0,0 +1,197 @@
+package bib
+
+import (
+	"fmt"
+	"strings"
+)
+
+// refBuilder joins non-empty reference fragments with ", ", except that a
+// fragment immediately following a quoted title gets a plain space -- the
+// comma is already inside the closing quote, matching the numeric/IEEE
+// convention of `"Title," Journal, ...`.
+type refBuilder struct {
+	parts []string
+}
+
+func (r *refBuilder) add(s string) {
+	if s != "" {
+		r.parts = append(r.parts, s)
+	}
+}
+
+func (r *refBuilder) addTitle(title string) {
+	if title != "" {
+		r.parts = append(r.parts, `"`+title+`,"`)
+	}
+}
+
+func (r *refBuilder) String() string {
+	var b strings.Builder
+	for i, p := range r.parts {
+		if i > 0 {
+			if strings.HasSuffix(r.parts[i-1], `,"`) {
+				b.WriteString(" ")
+			} else {
+				b.WriteString(", ")
+			}
+		}
+		b.WriteString(p)
+	}
+	return strings.TrimRight(b.String(), ".,") + "."
+}
+
+// FormatReference renders entry e as the n-th numbered reference, in a
+// plain numeric/IEEE-ish style. Field presence varies by entry type;
+// missing fields are simply omitted rather than leaving gaps.
+func FormatReference(n int, e Entry) string {
+	var body string
+	switch e.Type {
+	case "book":
+		body = formatBook(e.Fields)
+	case "inproceedings", "conference":
+		body = formatInproceedings(e.Fields)
+	case "misc":
+		body = formatMisc(e.Fields)
+	case "techreport":
+		body = formatTechreport(e.Fields)
+	case "phdthesis":
+		body = formatThesis(e.Fields, "Ph.D. dissertation")
+	case "mastersthesis":
+		body = formatThesis(e.Fields, "M.S. thesis")
+	default: // "article" and anything unrecognized use the article template
+		body = formatArticle(e.Fields)
+	}
+	return fmt.Sprintf("[%d] %s", n, body)
+}
+
+func formatArticle(f map[string]string) string {
+	var r refBuilder
+	r.add(formatAuthors(splitAuthors(f["author"])))
+	r.addTitle(f["title"])
+	r.add(emphasize(f["journal"]))
+	r.add(formatVolNo(f))
+	r.add(formatPages(f["pages"]))
+	r.add(formatMonthYear(f))
+	return r.String()
+}
+
+func formatBook(f map[string]string) string {
+	var r refBuilder
+	r.add(formatAuthors(splitAuthors(f["author"])))
+	r.add(emphasize(f["title"]))
+	if ed := f["edition"]; ed != "" {
+		r.add(ed + " ed.")
+	}
+	r.add(formatCityPublisher(f))
+	r.add(f["year"])
+	return r.String()
+}
+
+func formatInproceedings(f map[string]string) string {
+	var r refBuilder
+	r.add(formatAuthors(splitAuthors(f["author"])))
+	r.addTitle(f["title"])
+	if bt := f["booktitle"]; bt != "" {
+		r.add("in " + emphasize(bt))
+	}
+	r.add(f["address"])
+	r.add(f["year"])
+	r.add(formatPages(f["pages"]))
+	return r.String()
+}
+
+func formatMisc(f map[string]string) string {
+	var r refBuilder
+	r.add(formatAuthors(splitAuthors(f["author"])))
+	r.addTitle(f["title"])
+	r.add(f["year"])
+	if url := entryURL(f); url != "" {
+		r.add("[Online]. Available: " + url)
+	}
+	return r.String()
+}
+
+func formatTechreport(f map[string]string) string {
+	var r refBuilder
+	r.add(formatAuthors(splitAuthors(f["author"])))
+	r.addTitle(f["title"])
+	r.add(f["institution"])
+	r.add(f["address"])
+	if num := f["number"]; num != "" {
+		r.add("Rep. " + num)
+	}
+	r.add(f["year"])
+	return r.String()
+}
+
+func formatThesis(f map[string]string, kind string) string {
+	var r refBuilder
+	r.add(formatAuthors(splitAuthors(f["author"])))
+	r.addTitle(f["title"])
+	r.add(kind)
+	r.add(f["school"])
+	r.add(f["address"])
+	r.add(f["year"])
+	return r.String()
+}
+
+func emphasize(s string) string {
+	if s == "" {
+		return ""
+	}
+	return "*" + s + "*"
+}
+
+func formatCityPublisher(f map[string]string) string {
+	city, publisher := f["address"], f["publisher"]
+	switch {
+	case city != "" && publisher != "":
+		return city + ": " + publisher
+	case publisher != "":
+		return publisher
+	default:
+		return city
+	}
+}
+
+func formatVolNo(f map[string]string) string {
+	var parts []string
+	if v := f["volume"]; v != "" {
+		parts = append(parts, "vol. "+v)
+	}
+	if n := f["number"]; n != "" {
+		parts = append(parts, "no. "+n)
+	}
+	return strings.Join(parts, ", ")
+}
+
+func formatPages(raw string) string {
+	if raw == "" {
+		return ""
+	}
+	return "pp. " + strings.ReplaceAll(raw, "--", "–")
+}
+
+func formatMonthYear(f map[string]string) string {
+	month, year := f["month"], f["year"]
+	switch {
+	case month != "" && year != "":
+		return month + " " + year
+	case year != "":
+		return year
+	default:
+		return month
+	}
+}
+
+// entryURL returns a link target for an entry, preferring an explicit url
+// field and falling back to doi, for use as a citation hyperlink.
+func entryURL(f map[string]string) string {
+	if u := f["url"]; u != "" {
+		return u
+	}
+	if doi := f["doi"]; doi != "" {
+		return "https://doi.org/" + doi
+	}
+	return ""
+}
blob - /dev/null
blob + 1d8e60f3a977c483db000ae6a52b016bee203c15 (mode 644)
--- /dev/null
+++ internal/bib/format_test.go
@@ -0,0 +1,68 @@
+package bib
+
+import "testing"
+
+func TestFormatReference(t *testing.T) {
+	tests := []struct {
+		name  string
+		entry Entry
+		want  string
+	}{
+		{
+			name: "article",
+			entry: Entry{Type: "article", Key: "s2020", Fields: map[string]string{
+				"author": "Smith, John", "title": "A Great Paper",
+				"journal": "Journal of Things", "volume": "5", "number": "2",
+				"pages": "100--110", "year": "2020",
+			}},
+			want: `[1] J. Smith, "A Great Paper," *Journal of Things*, vol. 5, no. 2, pp. 100–110, 2020.`,
+		},
+		{
+			name: "book",
+			entry: Entry{Type: "book", Key: "k1997", Fields: map[string]string{
+				"author": "Knuth, Donald E.", "title": "The Art of Computer Programming",
+				"address": "Boston", "publisher": "Addison-Wesley", "year": "1997",
+			}},
+			want: `[2] D. E. Knuth, *The Art of Computer Programming*, Boston: Addison-Wesley, 1997.`,
+		},
+		{
+			name: "inproceedings",
+			entry: Entry{Type: "inproceedings", Key: "p2019", Fields: map[string]string{
+				"author": "Doe, Jane", "title": "Conference Paper",
+				"booktitle": "Proc. Big Conf", "address": "NYC", "year": "2019", "pages": "1--5",
+			}},
+			want: `[3] J. Doe, "Conference Paper," in *Proc. Big Conf*, NYC, 2019, pp. 1–5.`,
+		},
+		{
+			name: "misc",
+			entry: Entry{Type: "misc", Key: "m2022", Fields: map[string]string{
+				"author": "Roe, Sam", "title": "A Blog Post", "year": "2022",
+				"url": "https://example.com",
+			}},
+			want: `[4] S. Roe, "A Blog Post," 2022, [Online]. Available: https://example.com.`,
+		},
+		{
+			name: "misc with doi fallback",
+			entry: Entry{Type: "misc", Key: "d2023", Fields: map[string]string{
+				"author": "Roe, Sam", "title": "A Dataset", "year": "2023", "doi": "10.1/x",
+			}},
+			want: `[5] S. Roe, "A Dataset," 2023, [Online]. Available: https://doi.org/10.1/x.`,
+		},
+		{
+			name: "missing fields are simply omitted",
+			entry: Entry{Type: "article", Key: "bare", Fields: map[string]string{
+				"title": "Untitled",
+			}},
+			want: `[6] "Untitled,".`,
+		},
+	}
+
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := FormatReference(i+1, tt.entry)
+			if got != tt.want {
+				t.Errorf("FormatReference(%d, %+v) =\n  %q\nwant\n  %q", i+1, tt.entry, got, tt.want)
+			}
+		})
+	}
+}
blob - /dev/null
blob + a78c4a610dfa667a4892c3db19a9f03a377645b1 (mode 644)
--- /dev/null
+++ internal/bib/parse.go
@@ -0,0 +1,194 @@
+// Package bib parses BibTeX files and formats numbered references for
+// citations found in slide markdown.
+package bib
+
+import (
+	"io"
+	"os"
+	"strings"
+)
+
+// Entry is a single @type{key, ...} BibTeX entry. Fields are already
+// stripped of braces/quotes and accent-converted.
+type Entry struct {
+	Type   string
+	Key    string
+	Fields map[string]string
+}
+
+// ParseFile reads and parses a .bib file.
+func ParseFile(path string) (map[string]Entry, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return Parse(f)
+}
+
+// Parse reads a .bib document from r. Entries it can't make sense of are
+// skipped rather than causing the whole file to fail -- a malformed one
+// shouldn't take down a presentation.
+func Parse(r io.Reader) (map[string]Entry, error) {
+	data, err := io.ReadAll(r)
+	if err != nil {
+		return nil, err
+	}
+	return parseString(string(data)), nil
+}
+
+func parseString(s string) map[string]Entry {
+	entries := make(map[string]Entry)
+	runes := []rune(s)
+	i := 0
+	for i < len(runes) {
+		for i < len(runes) && runes[i] != '@' {
+			i++
+		}
+		if i >= len(runes) {
+			break
+		}
+		i++ // skip '@'
+
+		typeStart := i
+		for i < len(runes) && isLetter(runes[i]) {
+			i++
+		}
+		typ := strings.ToLower(string(runes[typeStart:i]))
+
+		i = skipSpace(runes, i)
+		if i >= len(runes) || runes[i] != '{' {
+			continue
+		}
+		bodyEnd := matchBrace(runes, i)
+		if bodyEnd < 0 {
+			break // unbalanced braces from here on; nothing more to parse
+		}
+		body := runes[i+1 : bodyEnd]
+		i = bodyEnd + 1
+
+		if typ == "comment" || typ == "string" || typ == "preamble" {
+			continue
+		}
+
+		key, fields := parseEntryBody(body)
+		if key == "" {
+			continue
+		}
+		entries[key] = Entry{Type: typ, Key: key, Fields: fields}
+	}
+	return entries
+}
+
+func parseEntryBody(body []rune) (string, map[string]string) {
+	segments := splitTopLevel(body, ',')
+	if len(segments) == 0 {
+		return "", nil
+	}
+	key := strings.TrimSpace(string(segments[0]))
+	if key == "" {
+		return "", nil
+	}
+
+	fields := make(map[string]string)
+	for _, seg := range segments[1:] {
+		s := strings.TrimSpace(string(seg))
+		if s == "" {
+			continue
+		}
+		eq := strings.IndexRune(s, '=')
+		if eq < 0 {
+			continue
+		}
+		name := strings.ToLower(strings.TrimSpace(s[:eq]))
+		fields[name] = parseValue(strings.TrimSpace(s[eq+1:]))
+	}
+	return key, fields
+}
+
+// parseValue strips the {...}/"..." delimiter (bare words/numbers pass
+// through as-is) and converts LaTeX accents in the result.
+func parseValue(raw string) string {
+	runes := []rune(raw)
+	if len(runes) == 0 {
+		return ""
+	}
+
+	var inner string
+	switch runes[0] {
+	case '{':
+		if end := matchBrace(runes, 0); end >= 0 {
+			inner = string(runes[1:end])
+		} else {
+			inner = string(runes[1:])
+		}
+	case '"':
+		if end := len(runes) - 1; end > 0 && runes[end] == '"' {
+			inner = string(runes[1:end])
+		} else {
+			inner = string(runes[1:])
+		}
+	default:
+		inner = raw
+	}
+	return stripAccents(inner)
+}
+
+// splitTopLevel splits runes on sep, ignoring separators nested inside
+// {...} or "...".
+func splitTopLevel(runes []rune, sep rune) [][]rune {
+	var parts [][]rune
+	depth := 0
+	inQuote := false
+	start := 0
+	for i, r := range runes {
+		switch r {
+		case '{':
+			depth++
+		case '}':
+			if depth > 0 {
+				depth--
+			}
+		case '"':
+			if depth == 0 {
+				inQuote = !inQuote
+			}
+		case sep:
+			if depth == 0 && !inQuote {
+				parts = append(parts, runes[start:i])
+				start = i + 1
+			}
+		}
+	}
+	parts = append(parts, runes[start:])
+	return parts
+}
+
+// matchBrace returns the index of the '}' matching runes[open] (which must
+// be '{'), or -1 if unbalanced.
+func matchBrace(runes []rune, open int) int {
+	depth := 0
+	for i := open; i < len(runes); i++ {
+		switch runes[i] {
+		case '{':
+			depth++
+		case '}':
+			depth--
+			if depth == 0 {
+				return i
+			}
+		}
+	}
+	return -1
+}
+
+func skipSpace(runes []rune, i int) int {
+	for i < len(runes) && (runes[i] == ' ' || runes[i] == '\t' || runes[i] == '\n' || runes[i] == '\r') {
+		i++
+	}
+	return i
+}
+
+func isLetter(r rune) bool {
+	return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
+}
blob - /dev/null
blob + 0ef2c8261bbc4773cd64c4f37d42496bc48ee752 (mode 644)
--- /dev/null
+++ internal/bib/parse_test.go
@@ -0,0 +1,74 @@
+package bib_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/maaslalani/slides/internal/bib"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const sampleBib = `
+@article{smith2020,
+  author = {Smith, John and Doe, Jane},
+  title = {A Great Paper About {IEEE} Things},
+  journal = {Journal of Things},
+  year = {2020},
+  volume = {5},
+  number = {2},
+  pages = {100--110}
+}
+
+@book{knuth1997,
+  author = "Knuth, Donald E.",
+  title = "The Art of Computer Programming",
+  publisher = {Addison-Wesley},
+  year = 1997
+}
+
+@misc{accented,
+  author = {M{\"u}ller, Hans and G{\'o}mez, Ana},
+  title = {Some {\'e}scaped Ch{\c c}ars},
+  year = {2021}
+}
+
+@comment{ignore me}
+
+@string{foo = "bar"}
+`
+
+func TestParse(t *testing.T) {
+	entries, err := bib.Parse(strings.NewReader(sampleBib))
+	require.NoError(t, err)
+	require.Len(t, entries, 3)
+
+	smith := entries["smith2020"]
+	assert.Equal(t, "article", smith.Type)
+	assert.Equal(t, "smith2020", smith.Key)
+	assert.Equal(t, "Smith, John and Doe, Jane", smith.Fields["author"])
+	assert.Equal(t, "A Great Paper About IEEE Things", smith.Fields["title"])
+	assert.Equal(t, "Journal of Things", smith.Fields["journal"])
+	assert.Equal(t, "2020", smith.Fields["year"])
+	assert.Equal(t, "5", smith.Fields["volume"])
+	assert.Equal(t, "100--110", smith.Fields["pages"])
+
+	knuth := entries["knuth1997"]
+	assert.Equal(t, "book", knuth.Type)
+	assert.Equal(t, "Knuth, Donald E.", knuth.Fields["author"])
+	assert.Equal(t, "The Art of Computer Programming", knuth.Fields["title"])
+	assert.Equal(t, "1997", knuth.Fields["year"])
+
+	accented := entries["accented"]
+	assert.Equal(t, "Müller, Hans and Gómez, Ana", accented.Fields["author"])
+	assert.Equal(t, "Some éscaped Chçars", accented.Fields["title"])
+
+	_, hasComment := entries["ignore me"]
+	assert.False(t, hasComment)
+}
+
+func TestParse_EmptyOrGarbage(t *testing.T) {
+	entries, err := bib.Parse(strings.NewReader("not a bibtex file at all"))
+	require.NoError(t, err)
+	assert.Empty(t, entries)
+}
blob - f876a793ce417e13678c4b54acab7bbd818efcba
blob + a3e169bf1d27d60df9bd5f8a63cf8dc0bd82cc8f
--- internal/meta/meta.go
+++ internal/meta/meta.go
@@ -15,19 +15,21 @@ import (
 // from values set to empty strings in the YAML header. We replace values not
 // set by defaults values when parsing a header.
 type parsedMeta struct {
-	Theme  *string `yaml:"theme"`
-	Author *string `yaml:"author"`
-	Date   *string `yaml:"date"`
-	Paging *string `yaml:"paging"`
+	Theme        *string `yaml:"theme"`
+	Author       *string `yaml:"author"`
+	Date         *string `yaml:"date"`
+	Paging       *string `yaml:"paging"`
+	Bibliography *string `yaml:"bibliography"`
 }
 
 // Meta contains all of the data to be parsed
 // out of a markdown file's header section
 type Meta struct {
-	Theme  string
-	Author string
-	Date   string
-	Paging string
+	Theme        string
+	Author       string
+	Date         string
+	Paging       string
+	Bibliography string
 }
 
 // New creates a new instance of the
@@ -84,6 +86,10 @@ func (m *Meta) Parse(header string) (*Meta, bool) {
 		m.Paging = fallback.Paging
 	}
 
+	if tmp.Bibliography != nil {
+		m.Bibliography = *tmp.Bibliography
+	}
+
 	return m, true
 }
 
blob - ed3b157c77527f64f9db45a9a3678880b751a849
blob + f7724c31153dacbd8a2f5162589e923b6c8dfead
--- internal/meta/meta_test.go
+++ internal/meta/meta_test.go
@@ -159,6 +159,27 @@ func TestMeta_ParseHeader(t *testing.T) {
 				Paging: "Slide %d / %d",
 			},
 		},
+		{
+			name:      "Parse bibliography from header",
+			slideshow: fmt.Sprintf("---\nbibliography: %q\n", "refs.bib"),
+			want: &meta.Meta{
+				Theme:        "default",
+				Author:       user.Name,
+				Date:         date,
+				Paging:       "Slide %d / %d",
+				Bibliography: "refs.bib",
+			},
+		},
+		{
+			name:      "Fallback to empty if no bibliography provided",
+			slideshow: "\n# Header Slide\n > Subtitle\n",
+			want: &meta.Meta{
+				Theme:  "default",
+				Author: user.Name,
+				Date:   date,
+				Paging: "Slide %d / %d",
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
blob - 9c974a382e2d7dee4a0d50c1952a20defb65d494
blob + e53110565cfaf49477231a0f5efdf9b85a9b87b6
--- internal/model/model.go
+++ internal/model/model.go
@@ -21,6 +21,7 @@ import (
 	uv "github.com/charmbracelet/ultraviolet"
 
 	"github.com/charmbracelet/glamour"
+	"github.com/maaslalani/slides/internal/bib"
 	"github.com/maaslalani/slides/internal/code"
 	"github.com/maaslalani/slides/internal/image"
 	"github.com/maaslalani/slides/internal/latex"
@@ -183,6 +184,11 @@ func (m *Model) Load() error {
 	if m.images == nil {
 		m.images = image.NewCache()
 	}
+	if metaData.Bibliography != "" {
+		if entries, err := bib.ParseFile(filepath.Join(m.imageBaseDir(), metaData.Bibliography)); err == nil {
+			m.Slides = bib.Process(m.Slides, entries)
+		}
+	}
 
 	return nil
 }