mini-docs 0.7.0

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
//! Coverage of the two URL styles, checked the way a server would: every URL the
//! build advertises must name a file the build wrote.

use std::fs;
use std::path::{Path, PathBuf};

use mini_docs::Builder;
use serde_json::Value;

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

/// Builds a fixed three-page site (one nested, one directory index) in the given URL
/// style, returning the output directory so paths can be asserted against disk.
///
/// The tempdir is returned alongside it because dropping it deletes the output.
fn build_site(pretty_urls: bool) -> (tempfile::TempDir, PathBuf) {
    let input = tempfile::tempdir().expect("create input tempdir");
    let templates = tempfile::tempdir().expect("create templates tempdir");
    let output = tempfile::tempdir().expect("create output tempdir");

    fs::write(
        input.path().join("article.md"),
        "# Article\n\n[Setup](guide/setup.md)\n",
    )
    .expect("write article.md");
    fs::create_dir_all(input.path().join("guide")).expect("create guide dir");
    fs::write(input.path().join("guide/setup.md"), "# Setup\n").expect("write setup.md");
    fs::write(input.path().join("guide/index.md"), "# Guide\n").expect("write index.md");
    fs::write(templates.path().join("page.html"), TEMPLATE).expect("write page.html");

    Builder::new(input.path())
        .templates(templates.path())
        .output(output.path())
        .default_template("page.html")
        .link_base("/")
        .data_json("data.json")
        .pretty_urls(pretty_urls)
        .build()
        .expect("build should succeed");

    let path = output.path().to_path_buf();
    (output, path)
}

/// Resolves a URL path the way a static server does: the file itself, or the
/// directory's `index.html`. Returns `None` when nothing would be served — a 404.
fn serve(output: &Path, url: &str) -> Option<PathBuf> {
    let relative = url.trim_start_matches('/');
    let candidate = if relative.is_empty() {
        output.to_path_buf()
    } else {
        output.join(relative)
    };

    if candidate.is_file() {
        return Some(candidate);
    }

    let index = candidate.join("index.html");
    index.is_file().then_some(index)
}

fn data_json_urls(output: &Path) -> Vec<String> {
    let raw = fs::read_to_string(output.join("data.json")).expect("read data.json");
    let entries: Value = serde_json::from_str(&raw).expect("parse data.json");

    entries
        .as_array()
        .expect("data.json is an array")
        .iter()
        .map(|entry| entry["url"].as_str().expect("url is a string").to_string())
        .collect()
}

#[test]
fn plain_urls_write_sibling_html_files() {
    let (_guard, output) = build_site(false);

    assert!(output.join("article.html").is_file());
    assert!(output.join("guide/setup.html").is_file());
    assert!(output.join("guide/index.html").is_file());
}

#[test]
fn pretty_urls_write_directory_indexes() {
    let (_guard, output) = build_site(true);

    assert!(output.join("article/index.html").is_file());
    assert!(output.join("guide/setup/index.html").is_file());
    assert!(
        !output.join("article.html").exists(),
        "the flat file must not also be written — two URLs for one page"
    );
}

#[test]
fn an_index_source_is_not_nested_a_second_time_under_pretty_urls() {
    let (_guard, output) = build_site(true);

    assert!(output.join("guide/index.html").is_file());
    assert!(
        !output.join("guide/index/index.html").exists(),
        "index.md is already its directory's index"
    );
}

/// The regression this feature exists for: `data.json` used to advertise
/// `/article` while the build wrote `article.html`, so every URL in the index 404'd.
#[test]
fn every_advertised_url_resolves_in_both_styles() {
    for pretty_urls in [false, true] {
        let (_guard, output) = build_site(pretty_urls);

        for url in data_json_urls(&output) {
            assert!(
                serve(&output, &url).is_some(),
                "data.json advertises {url}, which no file serves (pretty_urls = {pretty_urls})"
            );
        }
    }
}

#[test]
fn a_rewritten_internal_link_resolves_in_both_styles() {
    for pretty_urls in [false, true] {
        let (_guard, output) = build_site(pretty_urls);

        let article = serve(&output, "/article").or_else(|| serve(&output, "/article.html"));
        let html =
            fs::read_to_string(article.expect("article should be served")).expect("read article");

        let href = html
            .split(r#"href=""#)
            .nth(1)
            .and_then(|rest| rest.split('"').next())
            .expect("article should contain a rewritten link");

        assert!(
            serve(&output, href).is_some(),
            "article links to {href}, which no file serves (pretty_urls = {pretty_urls})"
        );
    }
}

#[test]
fn pretty_urls_advertise_extensionless_directory_urls() {
    let (_guard, output) = build_site(true);

    let urls = data_json_urls(&output);

    assert!(urls.contains(&"/article/".to_string()), "{urls:?}");
    assert!(urls.contains(&"/guide/setup/".to_string()), "{urls:?}");
    assert!(
        urls.iter().all(|url| !url.ends_with(".html")),
        "pretty URLs must carry no extension: {urls:?}"
    );
}

/// A URL that names a directory index without its trailing slash is not the page's
/// canonical address — every server answers it with a 301 to the slashed form. The
/// build must advertise the destination, not the redirect.
#[test]
fn no_advertised_pretty_url_would_be_redirected() {
    let (_guard, output) = build_site(true);

    for url in data_json_urls(&output) {
        assert!(
            url.ends_with('/'),
            "{url} names a directory index without its trailing slash: a 301"
        );
    }
}

#[test]
fn plain_urls_advertise_the_html_file() {
    let (_guard, output) = build_site(false);

    let urls = data_json_urls(&output);

    assert!(urls.contains(&"/article.html".to_string()), "{urls:?}");
    assert!(
        urls.iter().all(|url| url.ends_with(".html")),
        "plain URLs must name the file written: {urls:?}"
    );
}

/// A page's `id` is its identity in the index, not its address — switching URL style
/// must not renumber or rename anything downstream consumers key on.
#[test]
fn ids_are_unchanged_by_url_style() {
    let ids = |pretty_urls| {
        let (_guard, output) = build_site(pretty_urls);
        let raw = fs::read_to_string(output.join("data.json")).expect("read data.json");
        let entries: Value = serde_json::from_str(&raw).expect("parse data.json");
        let mut ids: Vec<String> = entries
            .as_array()
            .expect("data.json is an array")
            .iter()
            .map(|entry| entry["id"].as_str().expect("id is a string").to_string())
            .collect();
        ids.sort();
        ids
    };

    assert_eq!(ids(false), ids(true));
}