mdbook-bibtex 0.1.0

Adds support for bibliographies to mdbook. Backed by hayagriva, supporting all CSL citation styles.
Documentation
use anyhow::{Result, anyhow};
use hayagriva::{
    Library,
    archive::ArchivedStyle,
    citationberg::{IndependentStyle, Style},
    io::{from_biblatex_str, from_yaml_str},
};
use itertools::Itertools;
use mdbook_core::{config::Config as MdbookConfig, utils::fs};
use std::{path::PathBuf, str::FromStr};
use tracing::warn;

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum BibliographyLocation {
    /// Render bibliography once at the end of the book.
    Global,
    /// Render bibliography as a separate chapter at the end of the book.
    Chapter,
    /// Render bibliography as footnotes in each chapter.
    Footnotes,
}

impl FromStr for BibliographyLocation {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "global" => Ok(BibliographyLocation::Global),
            "chapter" => Ok(BibliographyLocation::Chapter),
            "footnotes" => Ok(BibliographyLocation::Footnotes),
            _ => Err(anyhow!(
                "Unknown bibliography location: {}. Supported locations are: global, chapter, footnotes",
                s
            )),
        }
    }
}

#[derive(Debug)]
pub(crate) struct Config {
    /// The title to use for the bibliography section. Defaults to "Bibliography".
    pub title: String,
    /// Bib file
    pub bib: Option<Library>,
    /// The citation style to use. Defaults to "apa".
    pub style: IndependentStyle,
    /// Whether to fail on missing citation keys. Defaults to true.
    pub errors_on_missing_keys: bool,
    /// Whether to render citations as footnotes in each chapter.
    pub biblio_location: BibliographyLocation,
    /// Whether to display all entries in the bibliography, even if not cited.
    pub display_all: bool,
    /// Whether to show a preview of the citation when hovering over the citation link.
    pub citation_preview: bool,
}

impl TryFrom<&MdbookConfig> for Config {
    type Error = anyhow::Error;

    fn try_from(mdbook_conf: &MdbookConfig) -> Result<Self, Self::Error> {
        let title = mdbook_conf
            .get::<String>("preprocessor.bibtex.title")?
            .unwrap_or_else(|| "Bibliography".to_string());

        let bib = mdbook_conf
            .get::<String>("preprocessor.bibtex.bibliography")?
            .map(PathBuf::from)
            .map(|bib_file| match bib_file.extension() {
                Some(ext) if ext == "bib" => {
                    let content = fs::read_to_string(bib_file)?;
                    from_biblatex_str(&content).map_err(|e| {
                        anyhow!("Failed to parse BibTeX file:\n{}", e.into_iter().join("\n"))
                    })
                }
                Some(ext) if ext == "yaml" => {
                    let content = fs::read_to_string(bib_file)?;
                    Ok(from_yaml_str(&content)?)
                }
                _ => Err(anyhow!(
                    "Unsupported bibliography file format. Supported formats are .bib and .yaml"
                )),
            })
            .transpose()?;

        let style = style_from_str(
            &mdbook_conf
                .get::<String>("preprocessor.bibtex.style")?
                .unwrap_or_else(|| "ieee".to_string()),
        )?;

        let errors_on_missing_keys = mdbook_conf
            .get::<bool>("preprocessor.bibtex.errors_on_missing_keys")?
            .unwrap_or(true);

        let biblio_location = mdbook_conf
            .get::<String>("preprocessor.bibtex.biblio_location")?
            .map(|s| s.parse())
            .transpose()?
            .unwrap_or(BibliographyLocation::Global);

        let display_all = mdbook_conf
            .get::<bool>("preprocessor.bibtex.display_all")?
            .unwrap_or(true);

        let mut citation_preview = mdbook_conf
            .get::<bool>("preprocessor.bibtex.citation_preview")?
            .unwrap_or(true);

        if biblio_location == BibliographyLocation::Footnotes && citation_preview {
            warn!(
                "Citation preview is not compatible with footnote bibliography location and will be disabled."
            );
            citation_preview = false;
        }

        Ok(Config {
            title,
            bib,
            style,
            errors_on_missing_keys,
            biblio_location,
            display_all,
            citation_preview,
        })
    }
}

fn style_from_str(style: &str) -> Result<IndependentStyle> {
    if style.ends_with(".xml") {
        Ok(IndependentStyle::from_xml(style)?)
    } else {
        ArchivedStyle::by_name(style.to_lowercase().as_str())
            .ok_or_else(|| {
                anyhow!(
                    "Unknown citation style: {}. Supported styles are: {}",
                    style,
                    ArchivedStyle::all()
                        .iter()
                        .filter(|s| match s.get() {
                            Style::Independent(i) => i.bibliography.is_some(),
                            Style::Dependent(_) => false,
                        })
                        .flat_map(|s| s.names())
                        .join(", ")
                )
            })
            .and_then(|style| match style.get() {
                Style::Independent(style) if style.bibliography.is_some() => Ok(style),
                Style::Independent(_) => Err(anyhow!(
                    "{} does not have a bibliography style and cannot be used. See https://github.com/typst/typst/issues/2707#issuecomment-1824488390 for details.",
                    style.display_name()
                )),
                Style::Dependent(_) => Err(anyhow!(
                    "{} is a dependent style but only independent styles are supported",
                    style.display_name()
                )),
            })
    }
}