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