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 fn alias_bomb_front_matter(levels: usize, width: usize) -> String {
431 let mut doc = String::from("---\n");
432 for level in 0..levels {
433 let item = if level == 0 {
434 "x".to_string()
435 } else {
436 format!("*l{}", level - 1)
437 };
438 let items = vec![item; width].join(", ");
439 doc.push_str(&format!("l{level}: &l{level} [{items}]\n"));
440 }
441 doc.push_str("---\n# Body\n");
442 doc
443 }
444
445 #[test]
450 fn yaml_front_matter_alias_amplification_is_refused() {
451 for (levels, width) in [(4, 30), (6, 10)] {
452 let doc = alias_bomb_front_matter(levels, width);
453 let (meta, body) = parse_frontmatter(&doc);
454 assert!(
455 meta.is_empty(),
456 "{}-byte front matter was accepted with {} keys",
457 doc.len(),
458 meta.len()
459 );
460 assert_eq!(body.trim(), "# Body");
461 }
462 }
463
464 #[test]
467 fn yaml_front_matter_modest_aliases_still_parse() {
468 let (meta, body) =
469 parse_frontmatter("---\nbase: &b [a, b]\ncopy: *b\n---\n# Body");
470 assert_eq!(meta.get("copy"), meta.get("base"));
471 assert!(meta.contains_key("base"));
472 assert_eq!(body.trim(), "# Body");
473 }
474
475 #[test]
487 fn code_spans_escape_html_tags() {
488 let html = compile_markdown(
489 "Every `<img>` has a meaningful `alt` (or `alt=\"\"`).",
490 );
491 assert!(
492 html.contains("<code><img></code>"),
493 "code span was not escaped: {html}"
494 );
495 assert!(
496 !html.contains("<code><img></code>"),
497 "code span emitted a real <img> element: {html}"
498 );
499 }
500
501 #[test]
502 fn fenced_blocks_escape_html_tags() {
503 let html = compile_markdown("```\n<script>alert(1)</script>\n```\n");
504 assert!(
505 html.contains("<script>"),
506 "fenced block was not escaped: {html}"
507 );
508 assert!(
509 !html.contains("<script>alert(1)</script>"),
510 "fenced block emitted executable markup: {html}"
511 );
512 }
513
514 use super::*;
515
516 #[test]
517 fn slugify_caps_length_in_bytes_not_characters() {
518 let arabic = "\u{0622}\u{0641}\u{0627}\u{0642} ".repeat(60);
523 let slug = slugify(&arabic);
524 assert!(
525 slug.len() <= MAX_SLUG_BYTES,
526 "slug is {} bytes, over the {MAX_SLUG_BYTES}-byte cap",
527 slug.len()
528 );
529 assert_ne!(slug, "");
531 assert!(!slug.ends_with('-'), "cut left a dangling separator");
532 }
533
534 #[test]
535 fn slugify_truncates_on_a_char_boundary() {
536 for n in 90..140 {
539 let slug = slugify(&"\u{3042}".repeat(n)); assert!(slug.len() <= MAX_SLUG_BYTES);
541 assert!(std::str::from_utf8(slug.as_bytes()).is_ok());
542 }
543 }
544
545 #[test]
546 fn slugify_leaves_short_slugs_untouched() {
547 assert_eq!(slugify("Hello World!"), "hello-world");
548 assert_eq!(slugify("Rust & Web"), "rust-web");
549 }
550
551 #[test]
552 fn split_terms_handles_non_ascii_separators() {
553 assert_eq!(split_terms("a, b, c").len(), 3);
555 assert_eq!(
556 split_terms("\u{0623}\u{060C} \u{0628}\u{060C} \u{062C}").len(),
557 3
558 );
559 assert_eq!(
560 split_terms("\u{3042}\u{3001}\u{3044}\u{3001}\u{3046}").len(),
561 3
562 );
563 assert_eq!(split_terms("\u{7532}\u{FF0C}\u{4E59}").len(), 2);
564 assert_eq!(split_terms("a; b").len(), 2);
565 }
566
567 #[test]
568 fn split_terms_trims_and_drops_empties() {
569 assert_eq!(split_terms(" a ,, b ,"), vec!["a", "b"]);
570 assert_eq!(split_terms(" , , "), Vec::<String>::new());
571 assert_eq!(split_terms(""), Vec::<String>::new());
572 }
573
574 #[test]
575 fn split_terms_then_slugify_stays_within_the_byte_cap() {
576 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";
579 let slugs: Vec<String> =
580 split_terms(list).iter().map(|t| slugify(t)).collect();
581 assert_eq!(slugs.len(), 3);
582 for s in &slugs {
583 assert!(s.len() <= MAX_SLUG_BYTES);
584 assert_ne!(s, "");
585 }
586 }
587
588 #[test]
589 fn compile_markdown_basic() {
590 let html = compile_markdown("# Hello\n\nParagraph.");
591 assert!(html.contains("<h1>Hello</h1>"));
592 assert!(html.contains("<p>Paragraph.</p>"));
593 }
594
595 #[test]
596 fn compile_markdown_gfm_tables() {
597 let input = "| A | B |\n|---|---|\n| 1 | 2 |";
598 let html = compile_markdown(input);
599 assert!(html.contains("<table>"));
600 }
601
602 #[test]
603 fn compile_markdown_strikethrough() {
604 let html = compile_markdown("~~deleted~~");
605 assert!(html.contains("<del>deleted</del>"));
606 }
607
608 #[test]
609 fn parse_frontmatter_yaml() {
610 let (fm, body) = parse_frontmatter(
611 "---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
612 );
613 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
614 assert!(body.contains("# Body"));
615 }
616
617 #[test]
618 fn parse_frontmatter_toml() {
619 let (fm, body) =
620 parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
621 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
622 assert!(body.contains("# Body"));
623 }
624
625 #[test]
626 fn parse_frontmatter_json() {
627 let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
628 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
629 assert!(body.contains("# Body"));
630 }
631
632 #[test]
633 fn parse_frontmatter_none() {
634 let (fm, body) = parse_frontmatter("Just content");
635 assert!(fm.is_empty());
636 assert_eq!(body, "Just content");
637 }
638
639 #[test]
640 fn compile_page_full() {
641 let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
642 let (fm, html) = compile_page(input).unwrap();
643 assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
644 assert!(html.contains("<h1>Hello</h1>"));
645 }
646
647 #[test]
648 fn strip_html_tags_basic() {
649 assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
650 }
651
652 #[test]
653 fn strip_html_tags_empty() {
654 assert_eq!(strip_html_tags(""), "");
655 }
656
657 #[test]
658 fn build_search_entry_strips_tags() {
659 let entry =
660 build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
661 assert_eq!(entry.title, "Title");
662 assert_eq!(entry.content, "Hello world");
663 }
664
665 #[test]
666 fn reading_time_short() {
667 assert_eq!(reading_time("one two three"), 1);
668 }
669
670 #[test]
671 fn reading_time_long() {
672 let text = "word ".repeat(600);
673 assert_eq!(reading_time(&text), 3);
674 }
675
676 #[test]
677 fn slugify_basic() {
678 assert_eq!(slugify("Hello World!"), "hello-world");
679 assert_eq!(slugify("Rust & Web"), "rust-web");
680 }
681
682 #[test]
683 fn error_display_frontmatter_parse_variant() {
684 let e = Error::FrontmatterParse {
685 syntax: "yaml mismatch".to_string(),
686 };
687 let s = format!("{e}");
688 assert!(s.contains("Frontmatter parse error"));
689 assert!(s.contains("yaml mismatch"));
690 }
691
692 #[test]
693 fn error_display_markdown_compile_variant() {
694 let e = Error::MarkdownCompile {
695 source: "broken markdown".to_string(),
696 };
697 let s = format!("{e}");
698 assert!(s.contains("Markdown compilation error"));
699 assert!(s.contains("broken markdown"));
700 }
701
702 #[test]
703 fn error_display_invalid_slug_variant() {
704 let e = Error::InvalidSlug {
705 input: "@@@".to_string(),
706 };
707 let s = format!("{e}");
708 assert!(s.contains("Invalid slug input"));
709 assert!(s.contains("@@@"));
710 }
711
712 #[test]
713 fn error_is_std_error_trait_object() {
714 let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
716 input: "x".to_string(),
717 });
718 assert_ne!(e.to_string(), "");
719 assert!(std::error::Error::source(&*e).is_none());
721 }
722
723 #[test]
724 fn error_debug_impl_executes_for_each_variant() {
725 let e1 = Error::FrontmatterParse {
726 syntax: "a".to_string(),
727 };
728 let e2 = Error::MarkdownCompile {
729 source: "b".to_string(),
730 };
731 let e3 = Error::InvalidSlug {
732 input: "c".to_string(),
733 };
734 for e in [&e1, &e2, &e3] {
735 let s = format!("{e:?}");
736 assert_ne!(s, "");
737 }
738 }
739
740 #[test]
741 fn search_entry_serialization_roundtrip() {
742 let e = SearchEntry {
743 title: "T".to_string(),
744 url: "/u".to_string(),
745 content: "C".to_string(),
746 };
747 let json = serde_json::to_string(&e).unwrap();
748 assert!(json.contains("\"title\":\"T\""));
749 let back: SearchEntry = serde_json::from_str(&json).unwrap();
750 assert_eq!(back.url, "/u");
751 assert_eq!(back.content, "C");
752 let _ = format!("{back:?}");
754 let _ = back.clone();
755 }
756
757 #[test]
758 fn compile_page_yields_empty_frontmatter_when_absent() {
759 let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
760 assert!(fm.is_empty());
761 assert!(html.contains("<h1>Heading</h1>"));
762 }
763
764 #[test]
765 fn slugify_collapses_consecutive_separators() {
766 assert_eq!(slugify("foo!!!bar"), "foo-bar");
767 assert_eq!(slugify("--leading--"), "leading");
768 }
769
770 #[test]
771 fn slugify_empty_input_yields_empty() {
772 assert_eq!(slugify(""), "");
773 assert_eq!(slugify("???"), "");
774 }
775}