calepin 0.0.54

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::path::Path;

pub(super) fn absolute_site_url(base_url: &str, href: &str) -> String {
    let base = base_url.trim_end_matches('/');
    let href = href.trim_start_matches('/');
    if href.is_empty() {
        base.to_string()
    } else {
        format!("{base}/{href}")
    }
}

pub(super) fn absolute_site_url_without_index(base_url: &str, href: &str) -> String {
    let base = absolute_site_url(base_url, href);
    if let Some(prefix) = base.strip_suffix("/index.html") {
        format!("{prefix}/")
    } else {
        base
    }
}

pub(super) fn clean_url_prefix(value: &str) -> String {
    value
        .trim()
        .trim_matches('/')
        .trim_start_matches("./")
        .to_string()
}

/// True when `value` is a relative route that cannot escape the output
/// directory once joined onto it.
pub(super) fn is_safe_output_route(value: &str) -> bool {
    let path = Path::new(value);
    !path.is_absolute()
        && path.components().all(|component| {
            matches!(
                component,
                std::path::Component::Normal(_) | std::path::Component::CurDir
            )
        })
}

pub(super) fn is_absolute_or_special_url(value: &str) -> bool {
    value.starts_with('/')
        || value.starts_with('#')
        || value.starts_with("data:")
        || value.starts_with("http://")
        || value.starts_with("https://")
        || value.starts_with("//")
        || value.starts_with("mailto:")
        || value.starts_with("tel:")
}

pub(super) fn page_relative_url(current_href: &str, target: &str) -> String {
    if is_absolute_or_special_url(target) {
        return target.to_string();
    }

    let target = target.trim_start_matches("./");

    let current_dir = current_href
        .split('/')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>();
    let current_dir = current_dir
        .get(..current_dir.len().saturating_sub(1))
        .unwrap_or(&[]);
    let target_parts = target
        .split('/')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>();
    let common_len = current_dir
        .iter()
        .zip(target_parts.iter())
        .take_while(|(left, right)| left == right)
        .count();
    let up_levels = current_dir.len().saturating_sub(common_len);
    let remaining_target = target_parts.get(common_len..).unwrap_or(&[]).join("/");

    match (up_levels, remaining_target.is_empty()) {
        (0, false) => remaining_target,
        (0, true) => target.to_string(),
        (_, false) => format!("{}{}", "../".repeat(up_levels), remaining_target),
        (_, true) => "../".repeat(up_levels).trim_end_matches('/').to_string(),
    }
}

/// How a page addresses the rest of the site. Ordinary pages are only ever
/// served from their own URL, so page-relative links are shortest and survive
/// a move of the whole site. The fallback (404) page is different: the host
/// serves it under whatever URL was requested, so links relative to the page
/// resolve against the wrong directory and break. That page links from the
/// site root instead, behind the path prefix of the configured `base-url`.
#[derive(Debug, Clone, Copy)]
pub(super) enum LinkStyle<'a> {
    PageRelative,
    SiteRoot { prefix: &'a str },
}

impl LinkStyle<'_> {
    pub(super) fn resolve(self, current_href: &str, target: &str) -> String {
        match self {
            LinkStyle::PageRelative => page_relative_url(current_href, target),
            LinkStyle::SiteRoot { prefix } => site_root_url(prefix, target),
        }
    }
}

/// Root-absolute URL for an output-root-relative `target`, under the site's
/// path prefix (empty when the site is hosted at a domain root).
pub(super) fn site_root_url(prefix: &str, target: &str) -> String {
    if is_absolute_or_special_url(target) {
        return target.to_string();
    }
    let target = target.trim_start_matches("./");
    let prefix = prefix.trim_end_matches('/');
    format!("{prefix}/{target}")
}