mini-docs 0.7.0

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
//! Maps a source `.md` path to the two things it determines: where its HTML is
//! written, and the URL that serves it.
//!
//! These are one decision, not two. Splitting them is how a generator ends up
//! advertising a URL nothing is written to — so both come out of [`route`] together,
//! and every caller that needs one takes the other from the same call.

use std::ffi::OsStr;
use std::path::{Path, PathBuf};

/// The file name that a server serves for a bare directory request.
const DIRECTORY_INDEX: &str = "index.html";
/// The stem of a source file that is already its directory's index.
const INDEX_STEM: &str = "index";
/// The extension every rendered page is written with.
const HTML_EXTENSION: &str = "html";

/// Where a page is written, and the URL path that reaches it.
///
/// `url_relative` is relative to `link_base` and carries no leading slash;
/// [`crate::page::page_url`] joins the two.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Route {
    /// The output path, relative to `output_dir`.
    pub(crate) output_relative: PathBuf,
    /// The URL path, relative to `link_base`.
    pub(crate) url_relative: String,
}

/// Routes `relative_md` — a `.md` path relative to `input_dir` — under the configured
/// URL style.
///
/// With `pretty_urls` off, `guide/setup.md` is written to `guide/setup.html` and
/// served at `guide/setup.html`. With it on, the same source is written to
/// `guide/setup/index.html` and served at `guide/setup/`, which resolves on any server
/// that serves a directory's `index.html` — every static host does.
///
/// The trailing slash is not decoration. A directory index requested without one is
/// not the canonical URL for that page: servers answer it with a 301 to the slashed
/// form (mini-static does, and so do nginx, Apache and GitHub Pages), because the
/// unslashed form resolves the page's relative links against its *parent* directory.
/// Advertising the slashed URL is what makes every internal link and `data.json` entry
/// land in one hop instead of two, and keeps one page from having two addresses.
///
/// A source file already named `index.md` is its directory's index in both styles, so
/// it keeps its own name rather than gaining a second level: `guide/index.md` is
/// written to `guide/index.html`, never `guide/index/index.html`. Under `pretty_urls`
/// it is served as the directory itself (`guide/`), which is what makes a section
/// landing page possible.
pub(crate) fn route(relative_md: &Path, pretty_urls: bool) -> Route {
    let stem = relative_md.with_extension("");
    let url_stem = to_url_path(&stem);

    if !pretty_urls {
        return Route {
            output_relative: relative_md.with_extension(HTML_EXTENSION),
            url_relative: format!("{url_stem}.{HTML_EXTENSION}"),
        };
    }

    if relative_md.file_stem() == Some(OsStr::new(INDEX_STEM)) {
        return Route {
            output_relative: relative_md.with_extension(HTML_EXTENSION),
            url_relative: as_directory(&drop_last_segment(&url_stem)),
        };
    }

    Route {
        output_relative: stem.join(DIRECTORY_INDEX),
        url_relative: as_directory(&url_stem),
    }
}

/// Rewrites a relative `something.md` link destination to match [`route`]'s URLs.
///
/// Operates on the destination as written in the document — which may be relative
/// (`../guide/setup.md`) — rather than on a path relative to `input_dir`, so it can
/// only apply the same suffix rule, not resolve the target. That is enough: the rule
/// depends on the file's own name, and a relative prefix passes through untouched.
pub(crate) fn route_link(dest_no_extension: &str, pretty_urls: bool) -> String {
    if !pretty_urls {
        return format!("{dest_no_extension}.{HTML_EXTENSION}");
    }

    if last_segment(dest_no_extension) == INDEX_STEM {
        return as_directory(&drop_last_segment(dest_no_extension));
    }

    as_directory(dest_no_extension)
}

/// Marks a URL path as naming a directory rather than a file, by ensuring it ends in
/// exactly one `/`.
///
/// An empty path is left empty: it is already the base itself, which
/// [`crate::page::join_url`] renders with the separating slash. Adding one here would
/// produce `//`.
fn as_directory(url_path: &str) -> String {
    if url_path.is_empty() || url_path.ends_with('/') {
        return url_path.to_string();
    }

    format!("{url_path}/")
}

/// Renders a path as a URL path: `/`-separated regardless of the host platform.
fn to_url_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn last_segment(url_path: &str) -> &str {
    url_path.rsplit('/').next().unwrap_or(url_path)
}

/// Drops the final `/`-separated segment, leaving the directory that contained it.
///
/// A single-segment path becomes `""` — the site root relative to `link_base`, which
/// [`crate::page::join_url`] renders as the base itself with a trailing slash.
fn drop_last_segment(url_path: &str) -> String {
    match url_path.rsplit_once('/') {
        Some((parent, _)) => parent.to_string(),
        None => String::new(),
    }
}

#[cfg(test)]
#[path = "../tests/unit/route.rs"]
mod tests;