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