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