1#![forbid(unsafe_code)]
2pub mod content_provider;
21pub mod isr_manifest;
22
23pub use content_provider::{
24 ContentProvider, FsContentProvider, MemoryContentProvider, ProviderError,
25 ProviderResult,
26};
27pub use isr_manifest::{
28 build_entry, hash_sources, CachePolicy, Manifest, ManifestEntry,
29 DEFAULT_SWR, DEFAULT_S_MAXAGE, MANIFEST_VERSION,
30};
31
32use std::collections::HashMap;
33use std::fmt;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Error {
47 FrontmatterParse {
49 syntax: String,
51 },
52 MarkdownCompile {
54 source: String,
56 },
57 InvalidSlug {
59 input: String,
61 },
62}
63
64impl fmt::Display for Error {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 Self::FrontmatterParse { syntax } => {
68 write!(f, "Frontmatter parse error: {syntax}")
69 }
70 Self::MarkdownCompile { source } => {
71 write!(f, "Markdown compilation error: {source}")
72 }
73 Self::InvalidSlug { input } => {
74 write!(f, "Invalid slug input: {input}")
75 }
76 }
77 }
78}
79
80impl std::error::Error for Error {}
81
82pub type Result<T> = std::result::Result<T, Error>;
84
85#[must_use]
97pub fn compile_markdown(input: &str) -> String {
98 use pulldown_cmark::{html, Options, Parser};
99
100 let options = Options::ENABLE_TABLES
101 | Options::ENABLE_STRIKETHROUGH
102 | Options::ENABLE_TASKLISTS;
103
104 let parser = Parser::new_ext(input, options);
105 let mut html_output = String::with_capacity(input.len() * 2);
106 html::push_html(&mut html_output, parser);
107 html_output
108}
109
110pub fn parse_frontmatter(
124 input: &str,
125) -> (HashMap<String, serde_json::Value>, String) {
126 let (map, body) = parse_frontmatter_borrowed(input);
130 (map, body.to_string())
131}
132
133fn parse_frontmatter_borrowed(
139 input: &str,
140) -> (HashMap<String, serde_json::Value>, &str) {
141 let trimmed = input.trim_start();
142
143 if let Some(after) = trimmed.strip_prefix("+++") {
145 if let Some(end) = after.find("+++") {
146 let fm_str = &after[..end];
147 let body = &after[end + 3..];
148 if let Ok(serde_json::Value::Object(map)) =
149 toml::from_str::<serde_json::Value>(fm_str)
150 {
151 return (map.into_iter().collect(), body);
154 }
155 return (HashMap::new(), body);
156 }
157 }
158
159 if let Some(after) = trimmed.strip_prefix("---") {
161 if let Some(end) = after.find("---") {
162 let fm_str = &after[..end];
163 let body = &after[end + 3..];
164 match noyalib::from_str::<serde_json::Value>(fm_str) {
165 Ok(serde_json::Value::Object(map)) => {
166 return (map.into_iter().collect(), body);
167 }
168 Ok(_) => {
169 return (HashMap::new(), body);
172 }
173 Err(e) => {
174 log::warn!("YAML frontmatter parse error: {e}");
175 return (HashMap::new(), body);
176 }
177 }
178 }
179 }
180
181 if trimmed.starts_with('{') {
183 let mut depth = 0;
185 let mut end = None;
186 for (i, c) in trimmed.char_indices() {
187 match c {
188 '{' => depth += 1,
189 '}' => {
190 depth -= 1;
191 if depth == 0 {
192 end = Some(i + 1);
193 break;
194 }
195 }
196 _ => {}
197 }
198 }
199 if let Some(end_pos) = end {
200 let fm_str = &trimmed[..end_pos];
201 let body = &trimmed[end_pos..];
202 if let Ok(map) = serde_json::from_str::<
203 HashMap<String, serde_json::Value>,
204 >(fm_str)
205 {
206 return (map, body);
207 }
208 }
209 }
210
211 (HashMap::new(), input)
212}
213
214pub fn compile_page(
232 input: &str,
233) -> Result<(HashMap<String, serde_json::Value>, String)> {
234 let (frontmatter, body) = parse_frontmatter(input);
235 let html = compile_markdown(&body);
236 Ok((frontmatter, html))
237}
238
239#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct SearchEntry {
254 pub title: String,
256 pub url: String,
258 pub content: String,
260}
261
262#[must_use]
271pub fn strip_html_tags(html: &str) -> String {
272 let mut result = String::with_capacity(html.len());
273 let mut in_tag = false;
274
275 for c in html.chars() {
276 match c {
277 '<' => in_tag = true,
278 '>' => in_tag = false,
279 _ if !in_tag => result.push(c),
280 _ => {}
281 }
282 }
283
284 result
285}
286
287#[must_use]
302pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
303 let content = strip_html_tags(html);
304 let content: String =
306 content.split_whitespace().collect::<Vec<_>>().join(" ");
307 SearchEntry {
308 title: title.to_string(),
309 url: url.to_string(),
310 content,
311 }
312}
313
314#[must_use]
326pub fn reading_time(text: &str) -> usize {
327 (text.split_whitespace().count() / 200).max(1)
328}
329
330const TERM_SEPARATORS: [char; 5] = [
339 ',', '\u{060C}', '\u{FF0C}', '\u{3001}', ';', ];
345
346#[must_use]
360pub fn split_terms(input: &str) -> Vec<String> {
361 input
362 .split(TERM_SEPARATORS)
363 .map(str::trim)
364 .filter(|s| !s.is_empty())
365 .map(ToOwned::to_owned)
366 .collect()
367}
368
369const MAX_SLUG_BYTES: usize = 200;
380
381#[must_use]
398pub fn slugify(input: &str) -> String {
399 let slug = input
400 .to_lowercase()
401 .chars()
402 .map(|c| if c.is_alphanumeric() { c } else { '-' })
403 .collect::<String>()
404 .split('-')
405 .filter(|s| !s.is_empty())
406 .collect::<Vec<_>>()
407 .join("-");
408
409 if slug.len() <= MAX_SLUG_BYTES {
410 return slug;
411 }
412
413 let mut end = MAX_SLUG_BYTES;
416 while end > 0 && !slug.is_char_boundary(end) {
417 end -= 1;
418 }
419 slug[..end].trim_end_matches('-').to_owned()
420}
421
422#[cfg(test)]
423#[allow(clippy::unwrap_used)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn slugify_caps_length_in_bytes_not_characters() {
429 let arabic = "\u{0622}\u{0641}\u{0627}\u{0642} ".repeat(60);
434 let slug = slugify(&arabic);
435 assert!(
436 slug.len() <= MAX_SLUG_BYTES,
437 "slug is {} bytes, over the {MAX_SLUG_BYTES}-byte cap",
438 slug.len()
439 );
440 assert!(!slug.is_empty());
442 assert!(!slug.ends_with('-'), "cut left a dangling separator");
443 }
444
445 #[test]
446 fn slugify_truncates_on_a_char_boundary() {
447 for n in 90..140 {
450 let slug = slugify(&"\u{3042}".repeat(n)); assert!(slug.len() <= MAX_SLUG_BYTES);
452 assert!(std::str::from_utf8(slug.as_bytes()).is_ok());
453 }
454 }
455
456 #[test]
457 fn slugify_leaves_short_slugs_untouched() {
458 assert_eq!(slugify("Hello World!"), "hello-world");
459 assert_eq!(slugify("Rust & Web"), "rust-web");
460 }
461
462 #[test]
463 fn split_terms_handles_non_ascii_separators() {
464 assert_eq!(split_terms("a, b, c").len(), 3);
466 assert_eq!(
467 split_terms("\u{0623}\u{060C} \u{0628}\u{060C} \u{062C}").len(),
468 3
469 );
470 assert_eq!(
471 split_terms("\u{3042}\u{3001}\u{3044}\u{3001}\u{3046}").len(),
472 3
473 );
474 assert_eq!(split_terms("\u{7532}\u{FF0C}\u{4E59}").len(), 2);
475 assert_eq!(split_terms("a; b").len(), 2);
476 }
477
478 #[test]
479 fn split_terms_trims_and_drops_empties() {
480 assert_eq!(split_terms(" a ,, b ,"), vec!["a", "b"]);
481 assert!(split_terms(" , , ").is_empty());
482 assert!(split_terms("").is_empty());
483 }
484
485 #[test]
486 fn split_terms_then_slugify_stays_within_the_byte_cap() {
487 let list = "\u{0623}\u{0644}\u{0623}\u{0639}\u{0645}\u{0627}\u{0644}\u{060C} \u{0627}\u{0644}\u{062A}\u{062C}\u{0627}\u{0631}\u{0629}\u{060C} DORA";
490 let slugs: Vec<String> =
491 split_terms(list).iter().map(|t| slugify(t)).collect();
492 assert_eq!(slugs.len(), 3);
493 for s in &slugs {
494 assert!(s.len() <= MAX_SLUG_BYTES);
495 assert!(!s.is_empty());
496 }
497 }
498
499 #[test]
500 fn compile_markdown_basic() {
501 let html = compile_markdown("# Hello\n\nParagraph.");
502 assert!(html.contains("<h1>Hello</h1>"));
503 assert!(html.contains("<p>Paragraph.</p>"));
504 }
505
506 #[test]
507 fn compile_markdown_gfm_tables() {
508 let input = "| A | B |\n|---|---|\n| 1 | 2 |";
509 let html = compile_markdown(input);
510 assert!(html.contains("<table>"));
511 }
512
513 #[test]
514 fn compile_markdown_strikethrough() {
515 let html = compile_markdown("~~deleted~~");
516 assert!(html.contains("<del>deleted</del>"));
517 }
518
519 #[test]
520 fn parse_frontmatter_yaml() {
521 let (fm, body) = parse_frontmatter(
522 "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
523 );
524 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
525 assert!(body.contains("# Body"));
526 }
527
528 #[test]
529 fn parse_frontmatter_toml() {
530 let (fm, body) =
531 parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
532 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
533 assert!(body.contains("# Body"));
534 }
535
536 #[test]
537 fn parse_frontmatter_json() {
538 let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
539 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
540 assert!(body.contains("# Body"));
541 }
542
543 #[test]
544 fn parse_frontmatter_none() {
545 let (fm, body) = parse_frontmatter("Just content");
546 assert!(fm.is_empty());
547 assert_eq!(body, "Just content");
548 }
549
550 #[test]
551 fn compile_page_full() {
552 let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
553 let (fm, html) = compile_page(input).unwrap();
554 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
555 assert!(html.contains("<h1>Hello</h1>"));
556 }
557
558 #[test]
559 fn strip_html_tags_basic() {
560 assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
561 }
562
563 #[test]
564 fn strip_html_tags_empty() {
565 assert_eq!(strip_html_tags(""), "");
566 }
567
568 #[test]
569 fn build_search_entry_strips_tags() {
570 let entry =
571 build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
572 assert_eq!(entry.title, "Title");
573 assert_eq!(entry.content, "Hello world");
574 }
575
576 #[test]
577 fn reading_time_short() {
578 assert_eq!(reading_time("one two three"), 1);
579 }
580
581 #[test]
582 fn reading_time_long() {
583 let text = "word ".repeat(600);
584 assert_eq!(reading_time(&text), 3);
585 }
586
587 #[test]
588 fn slugify_basic() {
589 assert_eq!(slugify("Hello World!"), "hello-world");
590 assert_eq!(slugify("Rust & Web"), "rust-web");
591 }
592
593 #[test]
594 fn error_display_frontmatter_parse_variant() {
595 let e = Error::FrontmatterParse {
596 syntax: "yaml mismatch".to_string(),
597 };
598 let s = format!("{e}");
599 assert!(s.contains("Frontmatter parse error"));
600 assert!(s.contains("yaml mismatch"));
601 }
602
603 #[test]
604 fn error_display_markdown_compile_variant() {
605 let e = Error::MarkdownCompile {
606 source: "broken markdown".to_string(),
607 };
608 let s = format!("{e}");
609 assert!(s.contains("Markdown compilation error"));
610 assert!(s.contains("broken markdown"));
611 }
612
613 #[test]
614 fn error_display_invalid_slug_variant() {
615 let e = Error::InvalidSlug {
616 input: "@@@".to_string(),
617 };
618 let s = format!("{e}");
619 assert!(s.contains("Invalid slug input"));
620 assert!(s.contains("@@@"));
621 }
622
623 #[test]
624 fn error_is_std_error_trait_object() {
625 let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
627 input: "x".to_string(),
628 });
629 assert!(!e.to_string().is_empty());
630 assert!(std::error::Error::source(&*e).is_none());
632 }
633
634 #[test]
635 fn error_debug_impl_executes_for_each_variant() {
636 let e1 = Error::FrontmatterParse {
637 syntax: "a".to_string(),
638 };
639 let e2 = Error::MarkdownCompile {
640 source: "b".to_string(),
641 };
642 let e3 = Error::InvalidSlug {
643 input: "c".to_string(),
644 };
645 for e in [&e1, &e2, &e3] {
646 let s = format!("{e:?}");
647 assert!(!s.is_empty());
648 }
649 }
650
651 #[test]
652 fn search_entry_serialization_roundtrip() {
653 let e = SearchEntry {
654 title: "T".to_string(),
655 url: "/u".to_string(),
656 content: "C".to_string(),
657 };
658 let json = serde_json::to_string(&e).unwrap();
659 assert!(json.contains("\"title\":\"T\""));
660 let back: SearchEntry = serde_json::from_str(&json).unwrap();
661 assert_eq!(back.url, "/u");
662 assert_eq!(back.content, "C");
663 let _ = format!("{back:?}");
665 let _ = back.clone();
666 }
667
668 #[test]
669 fn compile_page_yields_empty_frontmatter_when_absent() {
670 let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
671 assert!(fm.is_empty());
672 assert!(html.contains("<h1>Heading</h1>"));
673 }
674
675 #[test]
676 fn slugify_collapses_consecutive_separators() {
677 assert_eq!(slugify("foo!!!bar"), "foo-bar");
678 assert_eq!(slugify("--leading--"), "leading");
679 }
680
681 #[test]
682 fn slugify_empty_input_yields_empty() {
683 assert_eq!(slugify(""), "");
684 assert_eq!(slugify("???"), "");
685 }
686}