breb 0.5.0

the blog/reblog library and command-line tool
Documentation
//! parsing the file format for what will end up as html

use std::{borrow::Cow, collections::HashMap, fmt, fs, io, path::Path, str::FromStr};

use chrono::{DateTime, Locale, Utc};
use minijinja::{State, Value, value::ViaDeserialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor};

pub struct Parsed<T: Serialize + for<'a> Deserialize<'a>> {
	meta: DefaultMetadata<T>,
	body: String,
}

#[derive(Serialize, Deserialize)]
struct DefaultMetadata<T> {
	#[serde(default, serialize_with = "locale_ser", deserialize_with = "locale_de")]
	locale: Option<Locale>,
	#[serde(flatten)]
	extra: T,
}

impl<T: Serialize + for<'a> Deserialize<'a>> Parsed<T> {
	pub fn load(file: &Path) -> io::Result<Self> {
		// TODO: stream it more?
		let body = fs::read_to_string(file)?;
		let (meta, template) = body.split_once("\n---\n").unwrap_or(("", &body));
		let meta = toml::from_str(meta)
			.map_err(|e| io::Error::other(format!("invalid metadata: {e}")))?;
		Ok(Self {
			meta,
			body: template.to_string(),
		})
	}

	pub fn template_data(&self) -> HashMap<&'static str, Value> {
		let mut res = HashMap::new();
		if let Some(locale) = &self.meta.locale {
			res.insert("locale", Value::from_safe_string(locale.to_string()));
		}
		res
	}

	pub fn extra(&self) -> &T {
		&self.meta.extra
	}
	pub fn body(&self) -> &str {
		&self.body
	}
	pub fn into_both(self) -> (T, String) {
		(self.meta.extra, self.body)
	}
}

pub(crate) fn filter_strftime(
	state: &State,
	value: ViaDeserialize<DateTime<Utc>>,
	fmt: Option<Cow<'_, str>>,
) -> String {
	let locale: Locale = state
		.lookup("locale")
		.and_then(|v| v.as_str().and_then(|s| s.parse().ok()))
		.unwrap_or(Locale::POSIX);
	let fmt = fmt.unwrap_or("%Y-%m-%d".into());

	value.0.format_localized(&fmt, locale).to_string()
}

fn locale_ser<S: Serializer>(l: &Option<Locale>, s: S) -> Result<S::Ok, S::Error> {
	if let Some(l) = l {
		s.serialize_str(&format!("{l}"))
	} else {
		s.serialize_none()
	}
}

fn locale_de<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Locale>, D::Error> {
	struct LocVisitor;
	impl<'de> Visitor<'de> for LocVisitor {
		type Value = Option<Locale>;
		fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
			write!(f, "a string with a known locale")
		}
		fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
		where
			E: serde::de::Error,
		{
			match Locale::from_str(v) {
				Ok(v) => Ok(Some(v)),
				Err(_) => Err(E::custom(format!("unrecognized locale: {v:?}"))),
			}
		}
		fn visit_none<E>(self) -> Result<Self::Value, E>
		where
			E: serde::de::Error,
		{
			Ok(None)
		}
	}
	d.deserialize_str(LocVisitor)
}

pub(crate) fn get_preview(body: &str) -> (&str, bool) {
	const DEFAULT: (&str, bool) = ("(preview not available)", true);
	// const MAX_LEN: usize = 400;
	const IS_TEXT: &[&str] = &["p"];
	fn is_text(tag: &tl::HTMLTag<'_>) -> bool {
		IS_TEXT.iter().any(|t| tag.name() == *t)
	}

	let Ok(dom) = tl::parse(body, tl::ParserOptions::new()) else {
		return DEFAULT;
	};
	let mut children = dom.children().iter();
	let mut start = None;
	let mut end = None;
	for node in &mut children {
		let node = node.get(dom.parser()).unwrap();
		let Some(tag) = node.as_tag() else {
			continue;
		};
		let (s, e) = tag.boundaries(dom.parser());
		start.get_or_insert(s);
		if !is_text(tag) {
			continue;
		}
		end = Some(e);
		break;
	}
	let start = start.unwrap_or(0);
	let Some(end) = end else {
		return DEFAULT;
	};
	// TODO: if end - start > MAX_LEN: trim the html intelligently
	let cutoff = children.any(|e| {
		let Some(tag) = e.get(dom.parser()).unwrap().as_tag() else {
			return false;
		};
		is_text(tag)
	});
	(body[start..=end].into(), cutoff)
}