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]
361pub fn split_terms(input: &str) -> Vec<String> {
362 input
363 .split(TERM_SEPARATORS)
364 .map(str::trim)
365 .filter(|s| !s.is_empty())
366 .map(ToOwned::to_owned)
367 .collect()
368}
369
370const MAX_SLUG_BYTES: usize = 200;
381
382#[must_use]
399pub fn slugify(input: &str) -> String {
400 let slug = input
401 .to_lowercase()
402 .chars()
403 .map(|c| if c.is_alphanumeric() { c } else { '-' })
404 .collect::<String>()
405 .split('-')
406 .filter(|s| !s.is_empty())
407 .collect::<Vec<_>>()
408 .join("-");
409
410 if slug.len() <= MAX_SLUG_BYTES {
411 return slug;
412 }
413
414 let mut end = MAX_SLUG_BYTES;
417 while end > 0 && !slug.is_char_boundary(end) {
418 end -= 1;
419 }
420 slug[..end].trim_end_matches('-').to_owned()
421}
422
423#[cfg(test)]
424#[allow(clippy::unwrap_used)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn slugify_caps_length_in_bytes_not_characters() {
430 let arabic = "\u{0622}\u{0641}\u{0627}\u{0642} ".repeat(60);
435 let slug = slugify(&arabic);
436 assert!(
437 slug.len() <= MAX_SLUG_BYTES,
438 "slug is {} bytes, over the {MAX_SLUG_BYTES}-byte cap",
439 slug.len()
440 );
441 assert!(!slug.is_empty());
443 assert!(!slug.ends_with('-'), "cut left a dangling separator");
444 }
445
446 #[test]
447 fn slugify_truncates_on_a_char_boundary() {
448 for n in 90..140 {
451 let slug = slugify(&"\u{3042}".repeat(n)); assert!(slug.len() <= MAX_SLUG_BYTES);
453 assert!(std::str::from_utf8(slug.as_bytes()).is_ok());
454 }
455 }
456
457 #[test]
458 fn slugify_leaves_short_slugs_untouched() {
459 assert_eq!(slugify("Hello World!"), "hello-world");
460 assert_eq!(slugify("Rust & Web"), "rust-web");
461 }
462
463 #[test]
464 fn split_terms_handles_non_ascii_separators() {
465 assert_eq!(split_terms("a, b, c").len(), 3);
467 assert_eq!(
468 split_terms("\u{0623}\u{060C} \u{0628}\u{060C} \u{062C}").len(),
469 3
470 );
471 assert_eq!(
472 split_terms("\u{3042}\u{3001}\u{3044}\u{3001}\u{3046}").len(),
473 3
474 );
475 assert_eq!(split_terms("\u{7532}\u{FF0C}\u{4E59}").len(), 2);
476 assert_eq!(split_terms("a; b").len(), 2);
477 }
478
479 #[test]
480 fn split_terms_trims_and_drops_empties() {
481 assert_eq!(split_terms(" a ,, b ,"), vec!["a", "b"]);
482 assert!(split_terms(" , , ").is_empty());
483 assert!(split_terms("").is_empty());
484 }
485
486 #[test]
487 fn split_terms_then_slugify_stays_within_the_byte_cap() {
488 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";
491 let slugs: Vec<String> =
492 split_terms(list).iter().map(|t| slugify(t)).collect();
493 assert_eq!(slugs.len(), 3);
494 for s in &slugs {
495 assert!(s.len() <= MAX_SLUG_BYTES);
496 assert!(!s.is_empty());
497 }
498 }
499
500 #[test]
501 fn compile_markdown_basic() {
502 let html = compile_markdown("# Hello\n\nParagraph.");
503 assert!(html.contains("<h1>Hello</h1>"));
504 assert!(html.contains("<p>Paragraph.</p>"));
505 }
506
507 #[test]
508 fn compile_markdown_gfm_tables() {
509 let input = "| A | B |\n|---|---|\n| 1 | 2 |";
510 let html = compile_markdown(input);
511 assert!(html.contains("<table>"));
512 }
513
514 #[test]
515 fn compile_markdown_strikethrough() {
516 let html = compile_markdown("~~deleted~~");
517 assert!(html.contains("<del>deleted</del>"));
518 }
519
520 #[test]
521 fn parse_frontmatter_yaml() {
522 let (fm, body) = parse_frontmatter(
523 "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
524 );
525 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
526 assert!(body.contains("# Body"));
527 }
528
529 #[test]
530 fn parse_frontmatter_toml() {
531 let (fm, body) =
532 parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
533 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
534 assert!(body.contains("# Body"));
535 }
536
537 #[test]
538 fn parse_frontmatter_json() {
539 let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
540 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
541 assert!(body.contains("# Body"));
542 }
543
544 #[test]
545 fn parse_frontmatter_none() {
546 let (fm, body) = parse_frontmatter("Just content");
547 assert!(fm.is_empty());
548 assert_eq!(body, "Just content");
549 }
550
551 #[test]
552 fn compile_page_full() {
553 let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
554 let (fm, html) = compile_page(input).unwrap();
555 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
556 assert!(html.contains("<h1>Hello</h1>"));
557 }
558
559 #[test]
560 fn strip_html_tags_basic() {
561 assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
562 }
563
564 #[test]
565 fn strip_html_tags_empty() {
566 assert_eq!(strip_html_tags(""), "");
567 }
568
569 #[test]
570 fn build_search_entry_strips_tags() {
571 let entry =
572 build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
573 assert_eq!(entry.title, "Title");
574 assert_eq!(entry.content, "Hello world");
575 }
576
577 #[test]
578 fn reading_time_short() {
579 assert_eq!(reading_time("one two three"), 1);
580 }
581
582 #[test]
583 fn reading_time_long() {
584 let text = "word ".repeat(600);
585 assert_eq!(reading_time(&text), 3);
586 }
587
588 #[test]
589 fn slugify_basic() {
590 assert_eq!(slugify("Hello World!"), "hello-world");
591 assert_eq!(slugify("Rust & Web"), "rust-web");
592 }
593
594 #[test]
595 fn error_display_frontmatter_parse_variant() {
596 let e = Error::FrontmatterParse {
597 syntax: "yaml mismatch".to_string(),
598 };
599 let s = format!("{e}");
600 assert!(s.contains("Frontmatter parse error"));
601 assert!(s.contains("yaml mismatch"));
602 }
603
604 #[test]
605 fn error_display_markdown_compile_variant() {
606 let e = Error::MarkdownCompile {
607 source: "broken markdown".to_string(),
608 };
609 let s = format!("{e}");
610 assert!(s.contains("Markdown compilation error"));
611 assert!(s.contains("broken markdown"));
612 }
613
614 #[test]
615 fn error_display_invalid_slug_variant() {
616 let e = Error::InvalidSlug {
617 input: "@@@".to_string(),
618 };
619 let s = format!("{e}");
620 assert!(s.contains("Invalid slug input"));
621 assert!(s.contains("@@@"));
622 }
623
624 #[test]
625 fn error_is_std_error_trait_object() {
626 let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
628 input: "x".to_string(),
629 });
630 assert!(!e.to_string().is_empty());
631 assert!(std::error::Error::source(&*e).is_none());
633 }
634
635 #[test]
636 fn error_debug_impl_executes_for_each_variant() {
637 let e1 = Error::FrontmatterParse {
638 syntax: "a".to_string(),
639 };
640 let e2 = Error::MarkdownCompile {
641 source: "b".to_string(),
642 };
643 let e3 = Error::InvalidSlug {
644 input: "c".to_string(),
645 };
646 for e in [&e1, &e2, &e3] {
647 let s = format!("{e:?}");
648 assert!(!s.is_empty());
649 }
650 }
651
652 #[test]
653 fn search_entry_serialization_roundtrip() {
654 let e = SearchEntry {
655 title: "T".to_string(),
656 url: "/u".to_string(),
657 content: "C".to_string(),
658 };
659 let json = serde_json::to_string(&e).unwrap();
660 assert!(json.contains("\"title\":\"T\""));
661 let back: SearchEntry = serde_json::from_str(&json).unwrap();
662 assert_eq!(back.url, "/u");
663 assert_eq!(back.content, "C");
664 let _ = format!("{back:?}");
666 let _ = back.clone();
667 }
668
669 #[test]
670 fn compile_page_yields_empty_frontmatter_when_absent() {
671 let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
672 assert!(fm.is_empty());
673 assert!(html.contains("<h1>Heading</h1>"));
674 }
675
676 #[test]
677 fn slugify_collapses_consecutive_separators() {
678 assert_eq!(slugify("foo!!!bar"), "foo-bar");
679 assert_eq!(slugify("--leading--"), "leading");
680 }
681
682 #[test]
683 fn slugify_empty_input_yields_empty() {
684 assert_eq!(slugify(""), "");
685 assert_eq!(slugify("???"), "");
686 }
687}