mini-docs 0.7.0

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
#![cfg(feature = "cite")]

//! End-to-end coverage of the mini-cite processor running inside a real build.

use std::fs;

use mini_docs::{Builder, CiteProcessor, CiteStyle, DocError};

const BIB: &str = r#"
@article{smith2020,
  author = {Jane Smith},
  title = {On Small Things},
  journal = {Journal of Minimal Systems},
  year = {2020}
}
"#;

const TEMPLATE: &str = "{{ page.content | safe }}";

/// Builds one `.md` page against one `.bib` file in the default style.
fn build_page(markdown: &str, bib: &str) -> Result<String, DocError> {
    build_page_styled(markdown, bib, CiteStyle::default())
}

/// Builds one `.md` page against one `.bib` file and returns the rendered HTML.
fn build_page_styled(markdown: &str, bib: &str, style: CiteStyle) -> Result<String, DocError> {
    let input = tempfile::tempdir().expect("create input tempdir");
    let templates = tempfile::tempdir().expect("create templates tempdir");
    let output = tempfile::tempdir().expect("create output tempdir");
    let bib_dir = tempfile::tempdir().expect("create bib tempdir");

    fs::write(bib_dir.path().join("refs.bib"), bib).expect("write refs.bib");
    fs::write(input.path().join("page.md"), markdown).expect("write page.md");
    fs::write(templates.path().join("page.html"), TEMPLATE).expect("write page.html");

    let processor = CiteProcessor::new(bib_dir.path())
        .expect("bibliography should load")
        .with_style(style);

    Builder::new(input.path())
        .templates(templates.path())
        .output(output.path())
        .default_template("page.html")
        .processor(processor)
        .build()?;

    Ok(fs::read_to_string(output.path().join("page.html")).expect("read output file"))
}

#[test]
fn citation_becomes_a_footnote_reference_and_footnote_in_rendered_html() {
    let html =
        build_page("Small things matter [@smith2020].\n", BIB).expect("build should succeed");

    assert!(
        html.contains(r#"id="fnref-1""#),
        "inline footnote reference missing from rendered HTML: {html}"
    );
    assert!(
        html.contains(r##"href="#fn-1""##),
        "reference does not link to its footnote: {html}"
    );
    assert!(
        html.contains(r#"id="fn-1""#),
        "footnote list item missing from rendered HTML: {html}"
    );
    assert!(
        html.contains("Jane Smith. On Small Things. Journal of Minimal Systems. 2020."),
        "formatted citation missing from rendered HTML: {html}"
    );
    assert!(
        html.contains(r##"href="#fnref-1""##),
        "footnote back-link missing from rendered HTML: {html}"
    );
}

/// The footnote markup must survive sanitization: mini-docs cleans rendered HTML with
/// ammonia before a template marks it safe, so markup the policy strips would vanish
/// between mini-cite's output and the page on disk.
///
/// Attributes are matched individually rather than as whole tags because ammonia
/// rewrites anchors it keeps, adding `rel="noopener noreferrer"`. That addition is
/// harmless for same-page footnote links; what matters is that no element, `id`, or
/// `href` is dropped.
#[test]
#[cfg(feature = "sanitize")]
fn footnote_markup_survives_sanitization_intact() {
    let html = build_page("Cited [@smith2020].\n", BIB).expect("build should succeed");

    for fragment in [
        "<sup",
        r#"id="fnref-1""#,
        r##"href="#fn-1""##,
        "<hr",
        "<ol",
        "<li",
        r#"id="fn-1""#,
        r##"href="#fnref-1""##,
        "",
    ] {
        assert!(
            html.contains(fragment),
            "sanitizer stripped `{fragment}` from the output: {html}"
        );
    }
}

#[test]
fn footnote_block_is_its_own_block_not_glued_to_the_last_paragraph() {
    let html = build_page("Cited [@smith2020].\n", BIB).expect("build should succeed");

    let hr = html.find("<hr").expect("footnote block should be present");
    let paragraph_end = html[..hr]
        .rfind("</p>")
        .expect("body paragraph should be closed before the footnote block");

    assert!(
        paragraph_end < hr,
        "footnote block was absorbed into the preceding paragraph: {html}"
    );
}

#[test]
fn page_without_citations_renders_unchanged_and_gains_no_footnotes() {
    let html =
        build_page("# Title\n\nJust prose, no citations.\n", BIB).expect("build should succeed");

    assert!(html.contains("Just prose, no citations."));
    assert!(!html.contains("<hr"), "unexpected footnote block: {html}");
    assert!(!html.contains("fn-1"), "unexpected footnote: {html}");
}

#[test]
fn citation_in_a_code_block_is_left_literal() {
    let html = build_page(
        "Write it like this:\n\n```markdown\nSee [@smith2020].\n```\n",
        BIB,
    )
    .expect("build should succeed");

    assert!(
        html.contains("[@smith2020]"),
        "citation syntax in a code sample should render literally: {html}"
    );
    assert!(
        !html.contains("<hr"),
        "code sample produced a footnote: {html}"
    );
}

#[test]
fn undefined_citation_key_fails_the_build_with_a_cite_prefixed_error() {
    let result = build_page("Cite [@nosuchkey].\n", BIB);

    match result {
        Err(DocError::Extension(msg)) => {
            assert!(
                msg.starts_with("cite:"),
                "extension errors must be attributed to the extension: {msg}"
            );
            assert!(
                msg.contains("nosuchkey"),
                "error should name the undefined key: {msg}"
            );
        }
        other => panic!("expected DocError::Extension, got {other:?}"),
    }
}

#[test]
fn malformed_bibliography_fails_at_construction_not_at_build() {
    let bib_dir = tempfile::tempdir().expect("create bib tempdir");
    fs::write(
        bib_dir.path().join("broken.bib"),
        "@article{oops, title = {Unterminated}",
    )
    .expect("write broken.bib");

    let error = CiteProcessor::new(bib_dir.path())
        .err()
        .expect("malformed bibliography should not load");

    assert!(
        matches!(error, mini_cite::CiteError::Parse { .. }),
        "expected a parse error, got: {error}"
    );
}

#[test]
fn duplicate_citation_key_across_bib_files_fails_at_construction() {
    let bib_dir = tempfile::tempdir().expect("create bib tempdir");
    fs::write(bib_dir.path().join("a.bib"), "@article{dup, title = {A}}").expect("write a.bib");
    fs::write(bib_dir.path().join("b.bib"), "@article{dup, title = {B}}").expect("write b.bib");

    let error = CiteProcessor::new(bib_dir.path())
        .err()
        .expect("duplicate key should not load");

    assert!(
        matches!(error, mini_cite::CiteError::DuplicateKey { .. }),
        "expected a duplicate-key error, got: {error}"
    );
}

/// The point of configurable classes is that they reach the page. They only do so
/// because `sanitize_html` allowlists `class` — under ammonia's own default policy
/// this test fails on every fragment.
#[test]
fn custom_classes_reach_the_rendered_page() {
    let style = CiteStyle::default()
        .reference_class("c-ref")
        .separator_class("c-sep")
        .list_class("c-list")
        .item_class("c-item")
        .backlink_class("c-back");

    let html =
        build_page_styled("Cited [@smith2020].\n", BIB, style).expect("build should succeed");

    for fragment in [
        r#"class="c-ref""#,
        r#"class="c-sep""#,
        r#"class="c-list""#,
        r#"class="c-item""#,
        r#"class="c-back""#,
    ] {
        assert!(
            html.contains(fragment),
            "`{fragment}` did not survive to the rendered page: {html}"
        );
    }
}

#[test]
fn default_classes_reach_the_rendered_page() {
    let html = build_page("Cited [@smith2020].\n", BIB).expect("build should succeed");

    assert!(html.contains(r#"class="footnote-ref""#), "{html}");
    assert!(html.contains(r#"class="footnotes""#), "{html}");
    assert!(html.contains(r#"class="footnote-back""#), "{html}");
}

#[test]
fn a_custom_backlink_label_reaches_the_rendered_page() {
    let style = CiteStyle::default().backlink_label("back");

    let html =
        build_page_styled("Cited [@smith2020].\n", BIB, style).expect("build should succeed");

    assert!(
        html.contains(">back</a>"),
        "custom back-link label missing: {html}"
    );
    assert!(!html.contains(''), "default glyph survived: {html}");
}

#[test]
fn processor_reports_the_bibliography_it_loaded() {
    let bib_dir = tempfile::tempdir().expect("create bib tempdir");
    fs::write(bib_dir.path().join("refs.bib"), BIB).expect("write refs.bib");

    let processor = CiteProcessor::new(bib_dir.path()).expect("bibliography should load");

    assert_eq!(processor.len(), 1);
    assert!(!processor.is_empty());
    assert_eq!(processor.bib_dir(), bib_dir.path());
}