#![forbid(unsafe_code)]
pub mod content_provider;
pub mod isr_manifest;
pub use content_provider::{
ContentProvider, FsContentProvider, MemoryContentProvider, ProviderError,
ProviderResult,
};
pub use isr_manifest::{
build_entry, hash_sources, CachePolicy, Manifest, ManifestEntry,
DEFAULT_SWR, DEFAULT_S_MAXAGE, MANIFEST_VERSION,
};
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
FrontmatterParse {
syntax: String,
},
MarkdownCompile {
source: String,
},
InvalidSlug {
input: String,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FrontmatterParse { syntax } => {
write!(f, "Frontmatter parse error: {syntax}")
}
Self::MarkdownCompile { source } => {
write!(f, "Markdown compilation error: {source}")
}
Self::InvalidSlug { input } => {
write!(f, "Invalid slug input: {input}")
}
}
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
#[must_use]
pub fn compile_markdown(input: &str) -> String {
use pulldown_cmark::{html, Options, Parser};
let options = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS;
let parser = Parser::new_ext(input, options);
let mut html_output = String::with_capacity(input.len() * 2);
html::push_html(&mut html_output, parser);
html_output
}
pub fn parse_frontmatter(
input: &str,
) -> (HashMap<String, serde_json::Value>, String) {
let (map, body) = parse_frontmatter_borrowed(input);
(map, body.to_string())
}
fn parse_frontmatter_borrowed(
input: &str,
) -> (HashMap<String, serde_json::Value>, &str) {
let trimmed = input.trim_start();
if let Some(after) = trimmed.strip_prefix("+++") {
if let Some(end) = after.find("+++") {
let fm_str = &after[..end];
let body = &after[end + 3..];
if let Ok(serde_json::Value::Object(map)) =
toml::from_str::<serde_json::Value>(fm_str)
{
return (map.into_iter().collect(), body);
}
return (HashMap::new(), body);
}
}
if let Some(after) = trimmed.strip_prefix("---") {
if let Some(end) = after.find("---") {
let fm_str = &after[..end];
let body = &after[end + 3..];
match noyalib::from_str::<serde_json::Value>(fm_str) {
Ok(serde_json::Value::Object(map)) => {
return (map.into_iter().collect(), body);
}
Ok(_) => {
return (HashMap::new(), body);
}
Err(e) => {
log::warn!("YAML frontmatter parse error: {e}");
return (HashMap::new(), body);
}
}
}
}
if trimmed.starts_with('{') {
let mut depth = 0;
let mut end = None;
for (i, c) in trimmed.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(i + 1);
break;
}
}
_ => {}
}
}
if let Some(end_pos) = end {
let fm_str = &trimmed[..end_pos];
let body = &trimmed[end_pos..];
if let Ok(map) = serde_json::from_str::<
HashMap<String, serde_json::Value>,
>(fm_str)
{
return (map, body);
}
}
}
(HashMap::new(), input)
}
pub fn compile_page(
input: &str,
) -> Result<(HashMap<String, serde_json::Value>, String)> {
let (frontmatter, body) = parse_frontmatter(input);
let html = compile_markdown(&body);
Ok((frontmatter, html))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SearchEntry {
pub title: String,
pub url: String,
pub content: String,
}
#[must_use]
pub fn strip_html_tags(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
for c in html.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(c),
_ => {}
}
}
result
}
#[must_use]
pub fn build_search_entry(title: &str, url: &str, html: &str) -> SearchEntry {
let content = strip_html_tags(html);
let content: String =
content.split_whitespace().collect::<Vec<_>>().join(" ");
SearchEntry {
title: title.to_string(),
url: url.to_string(),
content,
}
}
#[must_use]
pub fn reading_time(text: &str) -> usize {
(text.split_whitespace().count() / 200).max(1)
}
const TERM_SEPARATORS: [char; 5] = [
',', '\u{060C}', '\u{FF0C}', '\u{3001}', ';', ];
#[must_use]
pub fn split_terms(input: &str) -> Vec<String> {
input
.split(TERM_SEPARATORS)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.collect()
}
const MAX_SLUG_BYTES: usize = 200;
#[must_use]
pub fn slugify(input: &str) -> String {
let slug = input
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.len() <= MAX_SLUG_BYTES {
return slug;
}
let mut end = MAX_SLUG_BYTES;
while end > 0 && !slug.is_char_boundary(end) {
end -= 1;
}
slug[..end].trim_end_matches('-').to_owned()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn slugify_caps_length_in_bytes_not_characters() {
let arabic = "\u{0622}\u{0641}\u{0627}\u{0642} ".repeat(60);
let slug = slugify(&arabic);
assert!(
slug.len() <= MAX_SLUG_BYTES,
"slug is {} bytes, over the {MAX_SLUG_BYTES}-byte cap",
slug.len()
);
assert!(!slug.is_empty());
assert!(!slug.ends_with('-'), "cut left a dangling separator");
}
#[test]
fn slugify_truncates_on_a_char_boundary() {
for n in 90..140 {
let slug = slugify(&"\u{3042}".repeat(n)); assert!(slug.len() <= MAX_SLUG_BYTES);
assert!(std::str::from_utf8(slug.as_bytes()).is_ok());
}
}
#[test]
fn slugify_leaves_short_slugs_untouched() {
assert_eq!(slugify("Hello World!"), "hello-world");
assert_eq!(slugify("Rust & Web"), "rust-web");
}
#[test]
fn split_terms_handles_non_ascii_separators() {
assert_eq!(split_terms("a, b, c").len(), 3);
assert_eq!(
split_terms("\u{0623}\u{060C} \u{0628}\u{060C} \u{062C}").len(),
3
);
assert_eq!(
split_terms("\u{3042}\u{3001}\u{3044}\u{3001}\u{3046}").len(),
3
);
assert_eq!(split_terms("\u{7532}\u{FF0C}\u{4E59}").len(), 2);
assert_eq!(split_terms("a; b").len(), 2);
}
#[test]
fn split_terms_trims_and_drops_empties() {
assert_eq!(split_terms(" a ,, b ,"), vec!["a", "b"]);
assert!(split_terms(" , , ").is_empty());
assert!(split_terms("").is_empty());
}
#[test]
fn split_terms_then_slugify_stays_within_the_byte_cap() {
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";
let slugs: Vec<String> =
split_terms(list).iter().map(|t| slugify(t)).collect();
assert_eq!(slugs.len(), 3);
for s in &slugs {
assert!(s.len() <= MAX_SLUG_BYTES);
assert!(!s.is_empty());
}
}
#[test]
fn compile_markdown_basic() {
let html = compile_markdown("# Hello\n\nParagraph.");
assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("<p>Paragraph.</p>"));
}
#[test]
fn compile_markdown_gfm_tables() {
let input = "| A | B |\n|---|---|\n| 1 | 2 |";
let html = compile_markdown(input);
assert!(html.contains("<table>"));
}
#[test]
fn compile_markdown_strikethrough() {
let html = compile_markdown("~~deleted~~");
assert!(html.contains("<del>deleted</del>"));
}
#[test]
fn parse_frontmatter_yaml() {
let (fm, body) = parse_frontmatter(
"---\ntitle: Hello\ndate: 2026-01-01\n---\n# Body",
);
assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
assert!(body.contains("# Body"));
}
#[test]
fn parse_frontmatter_toml() {
let (fm, body) =
parse_frontmatter("+++\ntitle = \"Hello\"\n+++\n# Body");
assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
assert!(body.contains("# Body"));
}
#[test]
fn parse_frontmatter_json() {
let (fm, body) = parse_frontmatter("{\"title\": \"Hello\"}\n# Body");
assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Hello"));
assert!(body.contains("# Body"));
}
#[test]
fn parse_frontmatter_none() {
let (fm, body) = parse_frontmatter("Just content");
assert!(fm.is_empty());
assert_eq!(body, "Just content");
}
#[test]
fn compile_page_full() {
let input = "---\ntitle: Test\n---\n# Hello\n\nWorld";
let (fm, html) = compile_page(input).unwrap();
assert_eq!(fm.get("title").and_then(|v| v.as_str()), Some("Test"));
assert!(html.contains("<h1>Hello</h1>"));
}
#[test]
fn strip_html_tags_basic() {
assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
}
#[test]
fn strip_html_tags_empty() {
assert_eq!(strip_html_tags(""), "");
}
#[test]
fn build_search_entry_strips_tags() {
let entry =
build_search_entry("Title", "/page", "<p>Hello <b>world</b></p>");
assert_eq!(entry.title, "Title");
assert_eq!(entry.content, "Hello world");
}
#[test]
fn reading_time_short() {
assert_eq!(reading_time("one two three"), 1);
}
#[test]
fn reading_time_long() {
let text = "word ".repeat(600);
assert_eq!(reading_time(&text), 3);
}
#[test]
fn slugify_basic() {
assert_eq!(slugify("Hello World!"), "hello-world");
assert_eq!(slugify("Rust & Web"), "rust-web");
}
#[test]
fn error_display_frontmatter_parse_variant() {
let e = Error::FrontmatterParse {
syntax: "yaml mismatch".to_string(),
};
let s = format!("{e}");
assert!(s.contains("Frontmatter parse error"));
assert!(s.contains("yaml mismatch"));
}
#[test]
fn error_display_markdown_compile_variant() {
let e = Error::MarkdownCompile {
source: "broken markdown".to_string(),
};
let s = format!("{e}");
assert!(s.contains("Markdown compilation error"));
assert!(s.contains("broken markdown"));
}
#[test]
fn error_display_invalid_slug_variant() {
let e = Error::InvalidSlug {
input: "@@@".to_string(),
};
let s = format!("{e}");
assert!(s.contains("Invalid slug input"));
assert!(s.contains("@@@"));
}
#[test]
fn error_is_std_error_trait_object() {
let e: Box<dyn std::error::Error> = Box::new(Error::InvalidSlug {
input: "x".to_string(),
});
assert!(!e.to_string().is_empty());
assert!(std::error::Error::source(&*e).is_none());
}
#[test]
fn error_debug_impl_executes_for_each_variant() {
let e1 = Error::FrontmatterParse {
syntax: "a".to_string(),
};
let e2 = Error::MarkdownCompile {
source: "b".to_string(),
};
let e3 = Error::InvalidSlug {
input: "c".to_string(),
};
for e in [&e1, &e2, &e3] {
let s = format!("{e:?}");
assert!(!s.is_empty());
}
}
#[test]
fn search_entry_serialization_roundtrip() {
let e = SearchEntry {
title: "T".to_string(),
url: "/u".to_string(),
content: "C".to_string(),
};
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("\"title\":\"T\""));
let back: SearchEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.url, "/u");
assert_eq!(back.content, "C");
let _ = format!("{back:?}");
let _ = back.clone();
}
#[test]
fn compile_page_yields_empty_frontmatter_when_absent() {
let (fm, html) = compile_page("# Heading\n\nBody").unwrap();
assert!(fm.is_empty());
assert!(html.contains("<h1>Heading</h1>"));
}
#[test]
fn slugify_collapses_consecutive_separators() {
assert_eq!(slugify("foo!!!bar"), "foo-bar");
assert_eq!(slugify("--leading--"), "leading");
}
#[test]
fn slugify_empty_input_yields_empty() {
assert_eq!(slugify(""), "");
assert_eq!(slugify("???"), "");
}
}