use std::path::Path;
use anyhow::Context;
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"
)
}
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")
}
fn skip_dir(name: &str) -> bool {
name.starts_with('.') || name == "node_modules"
}
pub struct Packed {
pub tar: Vec<u8>,
pub files: Vec<String>,
pub skipped: Vec<(String, &'static str)>,
}
pub fn pack_dir(root: &Path) -> anyhow::Result<Packed> {
let mut files = Vec::new();
let mut skipped = Vec::new();
walk(root, "", &mut files, &mut skipped)?;
files.sort();
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}"))?;
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,
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('.') {
} else if !allowed(&path) {
skipped.push((path, "file type not served"));
} else {
files.push(path);
}
}
Ok(())
}
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"]);
}
}