commit 372f60546819d91aaf4368cb7ea2b8a980156bfc from: ale date: Fri Jul 31 06:55:56 2026 UTC Add numbered bibliography and citation support Adds a hand-rolled BibTeX parser (internal/bib) that resolves [@key] citations in order of first appearance, rewrites them to numbered markdown links, and appends a generated References slide in an IEEE-ish format. Enabled per-presentation via a bibliography: field in the frontmatter; slides without it are unaffected. 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 }