1use crate::document::Document;
17use crate::yaml::Value;
18use std::collections::{BTreeMap, HashMap};
19use std::ffi::OsStr;
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24const INDEX_FILE: &str = "index.md";
25
26#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct IndexEntry {
30 pub type_: String,
32 pub title: String,
34 pub link: String,
36 pub description: String,
38}
39
40#[must_use]
44pub fn build_index_text(entries: &[IndexEntry]) -> String {
45 build_index_text_impl(entries, encode_link_destination)
46}
47
48fn build_index_text_with_encoded_links(entries: &[IndexEntry]) -> String {
52 build_index_text_impl(entries, str::to_owned)
53}
54
55fn build_index_text_impl<F>(entries: &[IndexEntry], encode_link: F) -> String
56where
57 F: Fn(&str) -> String,
58{
59 let mut grouped: BTreeMap<String, Vec<(&str, &str, &str)>> = BTreeMap::new();
60 for e in entries {
61 let key = if e.type_.is_empty() {
62 "Other".to_string()
63 } else {
64 e.type_.clone()
65 };
66 grouped
67 .entry(key)
68 .or_default()
69 .push((&e.title, &e.link, &e.description));
70 }
71
72 let mut sections: Vec<String> = Vec::new();
73 for (typ, mut items) in grouped {
74 items.sort_by_key(|a| a.0.to_lowercase());
75 let mut lines = vec![format!("# {}", escape_markdown_text(&typ)), String::new()];
76 for (title, link, desc) in items {
77 let title = escape_markdown_text(title);
78 let link = encode_link(link);
79 let desc = escape_markdown_text(desc);
80 let suffix = if desc.is_empty() {
81 String::new()
82 } else {
83 format!(" - {desc}")
84 };
85 lines.push(format!("* [{title}]({link}){suffix}"));
86 }
87 sections.push(lines.join("\n"));
88 }
89 format!("{}\n", sections.join("\n\n"))
90}
91
92fn escape_markdown_text(text: &str) -> String {
97 let mut escaped = String::with_capacity(text.len());
98 for c in text.chars() {
99 match c {
100 '\n' | '\r' => escaped.push(' '),
101 '\\' | '[' | ']' | '<' | '>' | '&' => {
102 escaped.push('\\');
103 escaped.push(c);
104 }
105 c => escaped.push(c),
106 }
107 }
108 escaped
109}
110
111fn encode_link_destination(link: &str) -> String {
116 percent_encode_path(link.as_bytes())
117}
118
119fn percent_encode_path(bytes: &[u8]) -> String {
120 const HEX: &[u8; 16] = b"0123456789ABCDEF";
121 let mut encoded = String::with_capacity(bytes.len());
122 for &byte in bytes {
123 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
124 encoded.push(byte as char);
125 } else {
126 encoded.push('%');
127 encoded.push(HEX[(byte >> 4) as usize] as char);
128 encoded.push(HEX[(byte & 0x0f) as usize] as char);
129 }
130 }
131 encoded
132}
133
134fn encoded_component(name: &OsStr) -> String {
138 #[cfg(unix)]
139 {
140 use std::os::unix::ffi::OsStrExt;
141 percent_encode_path(name.as_bytes())
142 }
143 #[cfg(not(unix))]
144 {
145 percent_encode_path(name.to_string_lossy().as_bytes())
146 }
147}
148
149pub type Synthesize<'a> = dyn Fn(&str, &[(String, String)]) -> String + 'a;
153
154#[must_use]
161pub fn default_synthesize(_rel: &str, children: &[(String, String)]) -> String {
162 if children.is_empty() {
163 return String::new();
164 }
165 let titles: Vec<&str> = children
166 .iter()
167 .map(|(title, _)| title.as_str())
168 .filter(|title| !title.is_empty())
169 .collect();
170 let titles = if titles.is_empty() {
171 "no titled entries".to_string()
172 } else {
173 titles.join(", ")
174 };
175 format!("Contains {} entries: {titles}.", children.len())
176}
177
178pub fn regenerate_indexes(bundle_root: impl AsRef<Path>) -> io::Result<Vec<PathBuf>> {
184 regenerate_indexes_with(bundle_root, &default_synthesize)
185}
186
187pub fn regenerate_indexes_with(
198 bundle_root: impl AsRef<Path>,
199 synthesize: &Synthesize,
200) -> io::Result<Vec<PathBuf>> {
201 let bundle_root = bundle_root.as_ref();
202 let mut written = Vec::new();
203 if !bundle_root.exists() {
204 return Ok(written);
205 }
206
207 let mut directories = directories_to_index(bundle_root)?;
208 directories.sort_by(|a, b| {
210 let da = depth(bundle_root, a);
211 let db = depth(bundle_root, b);
212 db.cmp(&da).then_with(|| a.cmp(b))
213 });
214
215 let mut dir_descriptions: HashMap<PathBuf, String> = HashMap::new();
216
217 for directory in &directories {
218 let mut entries: Vec<IndexEntry> = Vec::new();
219
220 let mut children: Vec<PathBuf> = fs::read_dir(directory)?
221 .filter_map(Result::ok)
222 .map(|e| e.path())
223 .collect();
224 children.sort();
225
226 for child in children {
227 let name = child
228 .file_name()
229 .map(|n| n.to_string_lossy().to_string())
230 .unwrap_or_default();
231 if crate::bundle::RESERVED_FILENAMES.contains(&name.as_str()) {
232 continue;
233 }
234 if child.is_file() && child.extension().is_some_and(|e| e == "md") {
235 let Some(doc) = load_doc(&child) else {
236 continue;
237 };
238 let stem = child
239 .file_stem()
240 .map(|s| s.to_string_lossy().to_string())
241 .unwrap_or_default();
242 let title = doc
245 .frontmatter
246 .title()
247 .filter(|t| !t.is_empty())
248 .map_or(stem, std::borrow::Cow::into_owned);
249 let description = doc
250 .frontmatter
251 .description()
252 .map(std::borrow::Cow::into_owned)
253 .unwrap_or_default();
254 let type_ = doc
255 .frontmatter
256 .type_()
257 .map(std::borrow::Cow::into_owned)
258 .unwrap_or_default();
259 entries.push(IndexEntry {
260 type_,
261 title,
262 link: encoded_component(child.file_name().unwrap_or_default()),
263 description,
264 });
265 } else if child.is_dir() {
266 let description = dir_descriptions.get(&child).cloned().unwrap_or_default();
267 let encoded_name = encoded_component(child.file_name().unwrap_or_default());
268 entries.push(IndexEntry {
269 type_: "Subdirectories".to_string(),
270 title: name.clone(),
271 link: format!("{encoded_name}/{INDEX_FILE}"),
272 description,
273 });
274 }
275 }
276
277 if entries.is_empty() {
278 continue;
279 }
280
281 written.push(write_index(directory, bundle_root, &entries)?);
282
283 if directory == bundle_root {
284 continue;
285 }
286
287 let pairs: Vec<(String, String)> = entries
288 .iter()
289 .map(|e| (e.title.clone(), e.description.clone()))
290 .collect();
291 let desc = if pairs.len() == 1 && !pairs[0].1.is_empty() {
292 pairs[0].1.clone()
293 } else {
294 let rel = directory
295 .strip_prefix(bundle_root)
296 .unwrap_or(directory)
297 .to_string_lossy()
298 .to_string();
299 synthesize(&rel, &pairs)
300 };
301 dir_descriptions.insert(directory.clone(), desc);
302 }
303
304 Ok(written)
305}
306
307fn load_doc(path: &Path) -> Option<Document> {
308 let text = fs::read_to_string(path).ok()?;
309 Document::parse(&text).ok()
310}
311
312fn write_index(
313 directory: &Path,
314 bundle_root: &Path,
315 entries: &[IndexEntry],
316) -> io::Result<PathBuf> {
317 let index_path = directory.join(INDEX_FILE);
318 let body = build_index_text_with_encoded_links(entries);
319 let text = if directory == bundle_root {
320 match preserved_frontmatter(&index_path) {
321 Some(fm) => format!("---\n{fm}---\n\n{body}"),
322 None => body,
323 }
324 } else {
325 body
326 };
327 fs::write(&index_path, text)?;
328 Ok(index_path)
329}
330
331fn preserved_frontmatter(index_path: &Path) -> Option<String> {
339 let doc = load_doc(index_path)?;
340 let version = doc.frontmatter.get("okf_version")?;
341 let mut kept = crate::yaml::Mapping::new();
342 kept.insert("okf_version", version.clone());
343 Some(Value::Mapping(kept).to_yaml_string())
344}
345
346fn depth(root: &Path, dir: &Path) -> usize {
347 dir.strip_prefix(root).map_or(0, |r| r.components().count())
348}
349
350fn directories_to_index(bundle_root: &Path) -> io::Result<Vec<PathBuf>> {
353 let mut md_files = Vec::new();
354 collect_markdown(bundle_root, &mut md_files)?;
355
356 let mut dirs: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
357 let root_parent = bundle_root.parent();
358 for md in &md_files {
359 let mut cur = md.parent();
360 while let Some(dir) = cur {
361 if Some(dir) == root_parent {
362 break;
363 }
364 dirs.insert(dir.to_path_buf());
365 if dir == bundle_root {
366 break;
367 }
368 cur = dir.parent();
369 }
370 }
371 Ok(dirs.into_iter().collect())
372}
373
374fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
375 for entry in fs::read_dir(dir)? {
376 let entry = entry?;
377 let path = entry.path();
378 if entry.file_type()?.is_dir() {
379 collect_markdown(&path, out)?;
380 } else if path.extension().is_some_and(|e| e == "md") {
381 out.push(path);
382 }
383 }
384 Ok(())
385}