1#![forbid(unsafe_code)]
2use std::collections::HashMap;
21use std::fmt;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Error {
26 FrontmatterParse {
28 syntax: String,
30 },
31 MarkdownCompile {
33 source: String,
35 },
36 InvalidSlug {
38 input: String,
40 },
41}
42
43impl fmt::Display for Error {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 Self::FrontmatterParse { syntax } => {
47 write!(f, "Frontmatter parse error: {syntax}")
48 }
49 Self::MarkdownCompile { source } => {
50 write!(f, "Markdown compilation error: {source}")
51 }
52 Self::InvalidSlug { input } => {
53 write!(f, "Invalid slug input: {input}")
54 }
55 }
56 }
57}
58
59impl std::error::Error for Error {}
60
61pub type Result<T> = std::result::Result<T, Error>;
63
64#[must_use]
76pub fn compile_markdown(input: &str) -> String {
77 use pulldown_cmark::{html, Options, Parser};
78
79 let options = Options::ENABLE_TABLES
80 | Options::ENABLE_STRIKETHROUGH
81 | Options::ENABLE_TASKLISTS;
82
83 let parser = Parser::new_ext(input, options);
84 let mut html_output = String::with_capacity(input.len() * 2);
85 html::push_html(&mut html_output, parser);
86 html_output
87}
88
89pub fn parse_frontmatter(
103 input: &str,
104) -> (HashMap<String, serde_json::Value>, String) {
105 let trimmed = input.trim_start();
106
107 if let Some(after) = trimmed.strip_prefix("+++") {
109 if let Some(end) = after.find("+++") {
110 let fm_str = &after[..end];
111 let body = &after[end + 3..];
112 if let Ok(value) = toml::from_str::<serde_json::Value>(fm_str) {
113 if let Some(map) = value.as_object() {
114 return (
115 map.iter()
116 .map(|(k, v)| (k.clone(), v.clone()))
117 .collect(),
118 body.to_string(),
119 );
120 }
121 }
122 return (HashMap::new(), body.to_string());
123 }
124 }
125
126 if let Some(after) = trimmed.strip_prefix("---") {
128 if let Some(end) = after.find("---") {
129 let fm_str = &after[..end].trim();
130 let body = &after[end + 3..];
131 let mut map = HashMap::new();
133 for line in fm_str.lines() {
134 if let Some((key, val)) = line.split_once(':') {
135 let key = key.trim().to_string();
136 let val = val.trim().to_string();
137 let _ = map.insert(key, serde_json::Value::String(val));
138 }
139 }
140 return (map, body.to_string());
141 }
142 }
143
144 if trimmed.starts_with('{') {
146 let mut depth = 0;
148 let mut end = None;
149 for (i, c) in trimmed.char_indices() {
150 match c {
151 '{' => depth += 1,
152 '}' => {
153 depth -= 1;
154 if depth == 0 {
155 end = Some(i + 1);
156 break;
157 }
158 }
159 _ => {}
160 }
161 }
162 if let Some(end_pos) = end {
163 let fm_str = &trimmed[..end_pos];
164 let body = &trimmed[end_pos..];
165 if let Ok(map) = serde_json::from_str::<
166 HashMap<String, serde_json::Value>,
167 >(fm_str)
168 {
169 return (map, body.to_string());
170 }
171 }
172 }
173
174 (HashMap::new(), input.to_string())
175}
176
177pub fn compile_page(
181 input: &str,
182) -> Result<(HashMap<String, serde_json::Value>, String)> {
183 let (frontmatter, body) = parse_frontmatter(input);
184 let html = compile_markdown(&body);
185 Ok((frontmatter, html))
186}
187
188#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
190pub struct SearchEntry {
191 pub title: String,
193 pub url: String,
195 pub content: String,
197}
198
199#[must_use]
201pub fn strip_html_tags(html: &str) -> String {
202 let mut result = String::with_capacity(html.len());
203 let mut in_tag = false;
204
205 for c in html.chars() {
206 match c {
207 '<' => in_tag = true,
208 '>' => in_tag = false,
209 _ if !in_tag => result.push(c),
210 _ => {}
211 }
212 }
213
214 result
215}
216
217#[must_use]
219pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
220 let content = strip_html_tags(html);
221 let content: String =
223 content.split_whitespace().collect::<Vec<_>>().join(" ");
224 SearchEntry {
225 title: title.to_string(),
226 url: url.to_string(),
227 content,
228 }
229}
230
231#[must_use]
235pub fn reading_time(text: &str) -> usize {
236 (text.split_whitespace().count() / 200).max(1)
237}
238
239#[must_use]
241pub fn slugify(input: &str) -> String {
242 input
243 .to_lowercase()
244 .chars()
245 .map(|c| if c.is_alphanumeric() { c } else { '-' })
246 .collect::<String>()
247 .split('-')
248 .filter(|s| !s.is_empty())
249 .collect::<Vec<_>>()
250 .join("-")
251}
252
253#[cfg(test)]
254#[allow(clippy::unwrap_used)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn compile_markdown_basic() {
260 let html = compile_markdown("# Hello\n\nParagraph.");
261 assert!(html.contains("<h1>Hello</h1>"));
262 assert!(html.contains("<p>Paragraph.</p>"));
263 }
264
265 #[test]
266 fn compile_markdown_gfm_tables() {
267 let input = "| A | B |\n|---|---|\n| 1 | 2 |";
268 let html = compile_markdown(input);
269 assert!(html.contains("<table>"));
270 }
271
272 #[test]
273 fn compile_markdown_strikethrough() {
274 let html = compile_markdown("~~deleted~~");
275 assert!(html.contains("<del>deleted</del>"));
276 }
277
278 #[test]
279 fn parse_frontmatter_yaml() {
280 let (fm, body) = parse_frontmatter(
281 "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
282 );
283 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
284 assert!(body.contains("# Body"));
285 }
286
287 #[test]
288 fn parse_frontmatter_toml() {
289 let (fm, body) =
290 parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
291 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
292 assert!(body.contains("# Body"));
293 }
294
295 #[test]
296 fn parse_frontmatter_json() {
297 let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
298 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
299 assert!(body.contains("# Body"));
300 }
301
302 #[test]
303 fn parse_frontmatter_none() {
304 let (fm, body) = parse_frontmatter("Just content");
305 assert!(fm.is_empty());
306 assert_eq!(body, "Just content");
307 }
308
309 #[test]
310 fn compile_page_full() {
311 let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
312 let (fm, html) = compile_page(input).unwrap();
313 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
314 assert!(html.contains("<h1>Hello</h1>"));
315 }
316
317 #[test]
318 fn strip_html_tags_basic() {
319 assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
320 }
321
322 #[test]
323 fn strip_html_tags_empty() {
324 assert_eq!(strip_html_tags(""), "");
325 }
326
327 #[test]
328 fn build_search_entry_strips_tags() {
329 let entry =
330 build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
331 assert_eq!(entry.title, "Title");
332 assert_eq!(entry.content, "Hello world");
333 }
334
335 #[test]
336 fn reading_time_short() {
337 assert_eq!(reading_time("one two three"), 1);
338 }
339
340 #[test]
341 fn reading_time_long() {
342 let text = "word ".repeat(600);
343 assert_eq!(reading_time(&text), 3);
344 }
345
346 #[test]
347 fn slugify_basic() {
348 assert_eq!(slugify("Hello World!"), "hello-world");
349 assert_eq!(slugify("Rust & Web"), "rust-web");
350 }
351}