1use std::path::Path;
4
5use anyhow::Context;
6
7pub fn allowed(path: &str) -> bool {
9 let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
10 path.contains('.')
11 && matches!(
12 ext.as_str(),
13 "md" | "markdown"
14 | "html"
15 | "htm"
16 | "css"
17 | "js"
18 | "mjs"
19 | "json"
20 | "svg"
21 | "png"
22 | "jpg"
23 | "jpeg"
24 | "gif"
25 | "webp"
26 | "avif"
27 | "bmp"
28 | "ico"
29 | "woff"
30 | "woff2"
31 | "ttf"
32 | "otf"
33 )
34}
35
36pub fn is_page(path: &str) -> bool {
37 let p = path.to_ascii_lowercase();
38 p.ends_with(".md") || p.ends_with(".markdown") || p.ends_with(".html") || p.ends_with(".htm")
39}
40
41fn skip_dir(name: &str) -> bool {
43 name.starts_with('.') || name == "node_modules"
44}
45
46pub struct Packed {
47 pub tar: Vec<u8>,
48 pub files: Vec<String>,
49 pub skipped: Vec<(String, &'static str)>,
51}
52
53pub fn pack_dir(root: &Path) -> anyhow::Result<Packed> {
54 let mut files = Vec::new();
55 let mut skipped = Vec::new();
56 walk(root, "", &mut files, &mut skipped)?;
57 files.sort();
58 let mut builder = tar::Builder::new(Vec::new());
59 for rel in &files {
60 let data = std::fs::read(root.join(rel)).with_context(|| format!("reading {rel}"))?;
61 let mut h = tar::Header::new_gnu();
62 h.set_size(data.len() as u64);
63 h.set_mode(0o644);
64 h.set_entry_type(tar::EntryType::Regular);
65 h.set_mtime(0);
66 builder
67 .append_data(&mut h, rel, data.as_slice())
68 .with_context(|| format!("adding {rel}"))?;
69 }
70 let tar = builder.into_inner()?;
71 Ok(Packed {
72 tar,
73 files,
74 skipped,
75 })
76}
77
78fn walk(
79 root: &Path,
80 rel: &str,
81 files: &mut Vec<String>,
82 skipped: &mut Vec<(String, &'static str)>,
83) -> anyhow::Result<()> {
84 let dir = if rel.is_empty() {
85 root.to_path_buf()
86 } else {
87 root.join(rel)
88 };
89 let mut entries: Vec<_> = std::fs::read_dir(&dir)
90 .with_context(|| format!("reading {}", dir.display()))?
91 .collect::<Result<_, _>>()?;
92 entries.sort_by_key(|e| e.file_name());
93 for e in entries {
94 let name = e.file_name().to_string_lossy().into_owned();
95 let path = if rel.is_empty() {
96 name.clone()
97 } else {
98 format!("{rel}/{name}")
99 };
100 let ft = e.file_type()?;
101 if ft.is_symlink() {
102 skipped.push((path, "symbolic link"));
103 } else if ft.is_dir() {
104 if !skip_dir(&name) {
105 walk(root, &path, files, skipped)?;
106 }
107 } else if name.starts_with('.') {
108 } else if !allowed(&path) {
110 skipped.push((path, "file type not served"));
111 } else {
112 files.push(path);
113 }
114 }
115 Ok(())
116}
117
118pub fn local_page_links(md: &str) -> Vec<String> {
120 let mut out = Vec::new();
121 let mut rest = md;
122 while let Some(i) = rest.find("](") {
123 rest = &rest[i + 2..];
124 let end = rest.find([')', ' ', '\n']).unwrap_or(rest.len());
125 let href = rest[..end].trim_matches(['<', '>']);
126 let path = href.split(['#', '?']).next().unwrap_or("");
127 let external = href.contains("://")
128 || href.starts_with("mailto:")
129 || href.starts_with('#')
130 || href.starts_with('/');
131 if !external && is_page(path) {
132 out.push(href.to_string());
133 }
134 rest = &rest[end..];
135 }
136 out
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn packs_and_skips() {
145 let dir = tempfile::tempdir().unwrap();
146 let w = |p: &str| {
147 let full = dir.path().join(p);
148 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
149 std::fs::write(full, "x").unwrap();
150 };
151 for p in [
152 "index.md",
153 "a/b.png",
154 ".env",
155 ".git/HEAD",
156 "node_modules/m/i.js",
157 "notes.txt",
158 "a/.hidden.md",
159 ] {
160 w(p);
161 }
162 let packed = pack_dir(dir.path()).unwrap();
163 assert_eq!(packed.files, vec!["a/b.png", "index.md"]);
164 assert_eq!(
165 packed.skipped,
166 vec![("notes.txt".to_string(), "file type not served")]
167 );
168 let mut archive = tar::Archive::new(packed.tar.as_slice());
169 let names: Vec<String> = archive
170 .entries()
171 .unwrap()
172 .map(|e| {
173 e.unwrap()
174 .path()
175 .unwrap()
176 .to_string_lossy()
177 .replace('\\', "/")
178 })
179 .collect();
180 assert_eq!(names, vec!["a/b.png", "index.md"]);
181 }
182
183 #[test]
184 fn finds_local_page_links() {
185 let md = "[a](guide.md) [b](https://x/y.md) [c](img.png) [d](sub/page.html#top) [e](#here)";
186 assert_eq!(local_page_links(md), vec!["guide.md", "sub/page.html#top"]);
187 }
188}