semora-cli 0.7.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Packing a folder into a tar for a bundle push.

use std::collections::BTreeMap;
use std::path::Path;

use anyhow::Context;

/// Extensions the server accepts in a bundle.
pub fn allowed(path: &str) -> bool {
    let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
    path.contains('.')
        && matches!(
            ext.as_str(),
            "md" | "markdown"
                | "html"
                | "htm"
                | "css"
                | "js"
                | "mjs"
                | "json"
                | "svg"
                | "png"
                | "jpg"
                | "jpeg"
                | "gif"
                | "webp"
                | "avif"
                | "bmp"
                | "ico"
                | "woff"
                | "woff2"
                | "ttf"
                | "otf"
                | "zip"
                | "7z"
                | "gz"
                | "rar"
        )
}

pub fn is_page(path: &str) -> bool {
    let p = path.to_ascii_lowercase();
    p.ends_with(".md") || p.ends_with(".markdown") || p.ends_with(".html") || p.ends_with(".htm")
}

/// Folders never worth uploading.
fn skip_dir(name: &str) -> bool {
    name.starts_with('.') || name == "node_modules"
}

/// Files left out on purpose, with the reason.
pub type Skipped = Vec<(String, &'static str)>;

pub struct Packed {
    pub tar: Vec<u8>,
    pub files: Vec<String>,
    /// The sha256 of each packed file, by its path in the folder.
    pub hashes: BTreeMap<String, String>,
    pub skipped: Skipped,
}

/// The files a push of `root` would send (sorted), and those it would leave out.
pub fn list(root: &Path) -> anyhow::Result<(Vec<String>, Skipped)> {
    let mut files = Vec::new();
    let mut skipped = Vec::new();
    walk(root, "", &mut files, &mut skipped)?;
    files.sort();
    Ok((files, skipped))
}

pub fn pack_dir(root: &Path) -> anyhow::Result<Packed> {
    let (files, skipped) = list(root)?;
    let mut hashes = BTreeMap::new();
    let mut builder = tar::Builder::new(Vec::new());
    for rel in &files {
        let data = std::fs::read(root.join(rel)).with_context(|| format!("reading {rel}"))?;
        hashes.insert(rel.clone(), crate::checkout::sha256(&data));
        let mut h = tar::Header::new_gnu();
        h.set_size(data.len() as u64);
        h.set_mode(0o644);
        h.set_entry_type(tar::EntryType::Regular);
        h.set_mtime(0);
        builder
            .append_data(&mut h, rel, data.as_slice())
            .with_context(|| format!("adding {rel}"))?;
    }
    let tar = builder.into_inner()?;
    Ok(Packed {
        tar,
        files,
        hashes,
        skipped,
    })
}

fn walk(
    root: &Path,
    rel: &str,
    files: &mut Vec<String>,
    skipped: &mut Vec<(String, &'static str)>,
) -> anyhow::Result<()> {
    let dir = if rel.is_empty() {
        root.to_path_buf()
    } else {
        root.join(rel)
    };
    let mut entries: Vec<_> = std::fs::read_dir(&dir)
        .with_context(|| format!("reading {}", dir.display()))?
        .collect::<Result<_, _>>()?;
    entries.sort_by_key(|e| e.file_name());
    for e in entries {
        let name = e.file_name().to_string_lossy().into_owned();
        let path = if rel.is_empty() {
            name.clone()
        } else {
            format!("{rel}/{name}")
        };
        let ft = e.file_type()?;
        if ft.is_symlink() {
            skipped.push((path, "symbolic link"));
        } else if ft.is_dir() {
            if !skip_dir(&name) {
                walk(root, &path, files, skipped)?;
            }
        } else if name.starts_with('.') {
            // Hidden files stay local.
        } else if !allowed(&path) {
            skipped.push((path, "file type not served"));
        } else {
            files.push(path);
        }
    }
    Ok(())
}

/// Relative links from a Markdown file to other local pages (for the bundle hint).
pub fn local_page_links(md: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut rest = md;
    while let Some(i) = rest.find("](") {
        rest = &rest[i + 2..];
        let end = rest.find([')', ' ', '\n']).unwrap_or(rest.len());
        let href = rest[..end].trim_matches(['<', '>']);
        let path = href.split(['#', '?']).next().unwrap_or("");
        let external = href.contains("://")
            || href.starts_with("mailto:")
            || href.starts_with('#')
            || href.starts_with('/');
        if !external && is_page(path) {
            out.push(href.to_string());
        }
        rest = &rest[end..];
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn packs_and_skips() {
        let dir = tempfile::tempdir().unwrap();
        let w = |p: &str| {
            let full = dir.path().join(p);
            std::fs::create_dir_all(full.parent().unwrap()).unwrap();
            std::fs::write(full, "x").unwrap();
        };
        for p in [
            "index.md",
            "a/b.png",
            ".env",
            ".git/HEAD",
            "node_modules/m/i.js",
            "notes.txt",
            "a/.hidden.md",
        ] {
            w(p);
        }
        let packed = pack_dir(dir.path()).unwrap();
        assert_eq!(packed.files, vec!["a/b.png", "index.md"]);
        assert_eq!(
            packed.skipped,
            vec![("notes.txt".to_string(), "file type not served")]
        );
        let mut archive = tar::Archive::new(packed.tar.as_slice());
        let names: Vec<String> = archive
            .entries()
            .unwrap()
            .map(|e| {
                e.unwrap()
                    .path()
                    .unwrap()
                    .to_string_lossy()
                    .replace('\\', "/")
            })
            .collect();
        assert_eq!(names, vec!["a/b.png", "index.md"]);
    }

    #[test]
    fn finds_local_page_links() {
        let md = "[a](guide.md) [b](https://x/y.md) [c](img.png) [d](sub/page.html#top) [e](#here)";
        assert_eq!(local_page_links(md), vec!["guide.md", "sub/page.html#top"]);
    }
}