2022-12-10 13:15:32 -05:00
|
|
|
use crate::generator;
|
|
|
|
use crate::generator::markdown;
|
|
|
|
use crate::generator::util::slugify::slugify;
|
|
|
|
use crate::generator::util::{from_frontmatter, word_count};
|
|
|
|
use anyhow::anyhow;
|
|
|
|
use chrono::{DateTime, Datelike, FixedOffset};
|
|
|
|
use once_cell::sync::Lazy;
|
|
|
|
use regex::Regex;
|
|
|
|
use serde::de::{SeqAccess, Visitor};
|
|
|
|
use serde::Deserialize;
|
|
|
|
use std::fs;
|
|
|
|
use std::hash::Hash;
|
|
|
|
use std::io::Read;
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
pub async fn parse() -> anyhow::Result<Vec<Post<HtmlContent>>> {
|
|
|
|
let posts_path = generator::content_path("posts/");
|
|
|
|
let mut posts = vec![];
|
|
|
|
for ent in fs::read_dir(posts_path)? {
|
|
|
|
let ent = ent.unwrap();
|
|
|
|
let path: PathBuf;
|
|
|
|
if ent.file_type().unwrap().is_dir() {
|
|
|
|
path = find_index(ent.path()).expect("folder posts must have index file");
|
|
|
|
} else {
|
|
|
|
path = ent.path();
|
|
|
|
}
|
|
|
|
let post = parse_post(path)?;
|
|
|
|
let prepared = prepare_post(post);
|
|
|
|
posts.push(prepared);
|
|
|
|
}
|
|
|
|
posts.sort_by(|a, b| b.metadata.date.cmp(&a.metadata.date));
|
|
|
|
Ok(posts)
|
|
|
|
}
|
|
|
|
|
|
|
|
fn find_index(path: PathBuf) -> Option<PathBuf> {
|
|
|
|
let dir = std::fs::read_dir(path).ok()?;
|
|
|
|
dir.map(|e| e.unwrap())
|
|
|
|
.find(|e| e.path().file_stem().unwrap().eq_ignore_ascii_case("index"))
|
|
|
|
.map(|e| e.path())
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn parse_post(path: PathBuf) -> anyhow::Result<Post<AnyContent>> {
|
|
|
|
let mut f = fs::File::open(&path)?;
|
|
|
|
let mut buffer = String::new();
|
|
|
|
f.read_to_string(&mut buffer)?;
|
|
|
|
Post::new(path, &buffer)
|
|
|
|
}
|
|
|
|
|
|
|
|
static EXCERPT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("<!--\\s*excerpt-end\\s*-->").unwrap());
|
|
|
|
|
|
|
|
pub fn prepare_post(post: Post<AnyContent>) -> Post<HtmlContent> {
|
|
|
|
let wc = post.content.word_count();
|
|
|
|
let mut new_post = post.to_html();
|
|
|
|
new_post.word_count = Some(wc);
|
|
|
|
if let Some(m) = EXCERPT_REGEX.find(&new_post.content.0) {
|
|
|
|
new_post.excerpt = Some(new_post.content.0[0..m.start()].trim().into());
|
|
|
|
}
|
|
|
|
new_post
|
|
|
|
}
|
|
|
|
|
2024-11-05 11:19:48 -05:00
|
|
|
#[derive(Debug, PartialEq)]
|
2022-12-10 13:15:32 -05:00
|
|
|
pub struct Post<Content: PostContent> {
|
|
|
|
pub path: PathBuf,
|
|
|
|
pub metadata: PostMetadata,
|
|
|
|
pub slug: String,
|
|
|
|
pub word_count: Option<u32>,
|
|
|
|
pub excerpt: Option<String>,
|
|
|
|
pub content: Content,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Post<AnyContent> {
|
|
|
|
pub fn new(path: PathBuf, contents: &str) -> anyhow::Result<Self> {
|
|
|
|
let (metadata, rest_contents) = match from_frontmatter::<'_, PostMetadata>(contents) {
|
|
|
|
Ok(res) => res,
|
|
|
|
Err(e) => return Err(e),
|
|
|
|
};
|
|
|
|
|
|
|
|
let slug = metadata
|
|
|
|
.slug
|
|
|
|
.clone()
|
|
|
|
.unwrap_or_else(|| slugify(&metadata.title));
|
|
|
|
|
|
|
|
let ext = path.extension().unwrap().to_str().unwrap();
|
|
|
|
let content = content_from(ext, rest_contents)?;
|
|
|
|
|
|
|
|
Ok(Post {
|
|
|
|
path,
|
|
|
|
metadata,
|
|
|
|
slug: slug.to_owned(),
|
|
|
|
word_count: None,
|
|
|
|
excerpt: None,
|
|
|
|
content,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl<C: PostContent> Post<C> {
|
|
|
|
fn to_html(self) -> Post<HtmlContent> {
|
|
|
|
Post {
|
|
|
|
path: self.path,
|
|
|
|
metadata: self.metadata,
|
|
|
|
slug: self.slug,
|
|
|
|
word_count: self.word_count,
|
|
|
|
excerpt: self.excerpt,
|
|
|
|
content: self.content.to_html(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn permalink(&self) -> String {
|
|
|
|
format!("/{}/{}/", self.metadata.date.year(), self.slug)
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn comments_permalink(&self) -> String {
|
|
|
|
match self.metadata.use_old_permalink_for_comments {
|
|
|
|
Some(true) => {
|
|
|
|
let old = self.metadata.old_permalink.as_ref().unwrap();
|
|
|
|
assert_eq!(old.len(), 1);
|
|
|
|
return old[0].clone();
|
|
|
|
}
|
|
|
|
_ => self.permalink(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-05 11:19:48 -05:00
|
|
|
#[derive(Debug, Deserialize, PartialEq, Clone)]
|
2022-12-10 13:15:32 -05:00
|
|
|
#[serde(deny_unknown_fields)]
|
|
|
|
pub struct PostMetadata {
|
|
|
|
pub title: String,
|
|
|
|
pub html_title: Option<String>,
|
|
|
|
pub tags: Option<Vec<Tag>>,
|
|
|
|
pub date: DateTime<FixedOffset>,
|
|
|
|
pub short_desc: Option<String>,
|
|
|
|
slug: Option<String>,
|
|
|
|
pub preamble: Option<String>,
|
|
|
|
#[serde(deserialize_with = "deserialize_old_permalink", default)]
|
|
|
|
pub old_permalink: Option<Vec<String>>,
|
|
|
|
pub use_old_permalink_for_comments: Option<bool>,
|
|
|
|
pub card_image_path: Option<String>,
|
|
|
|
}
|
|
|
|
|
|
|
|
fn deserialize_old_permalink<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
|
|
|
|
where
|
|
|
|
D: serde::Deserializer<'de>,
|
|
|
|
{
|
|
|
|
struct StringOrVec;
|
|
|
|
impl<'de> Visitor<'de> for StringOrVec {
|
|
|
|
type Value = Option<Vec<String>>;
|
|
|
|
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
|
|
formatter.write_str("string or vec of strings")
|
|
|
|
}
|
|
|
|
|
|
|
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
|
|
|
where
|
|
|
|
E: serde::de::Error,
|
|
|
|
{
|
|
|
|
Ok(Some(vec![value.to_owned()]))
|
|
|
|
}
|
|
|
|
|
|
|
|
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
|
|
|
|
where
|
|
|
|
A: SeqAccess<'de>,
|
|
|
|
{
|
|
|
|
let mut vec = Vec::with_capacity(seq.size_hint().unwrap_or(1));
|
|
|
|
loop {
|
|
|
|
match seq.next_element::<String>() {
|
|
|
|
Ok(Some(s)) => vec.push(s),
|
|
|
|
Ok(None) => break,
|
|
|
|
Err(e) => return Err(e),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
Ok(Some(vec))
|
|
|
|
}
|
|
|
|
|
|
|
|
fn visit_none<E>(self) -> Result<Self::Value, E>
|
|
|
|
where
|
|
|
|
E: serde::de::Error,
|
|
|
|
{
|
|
|
|
println!("visit_none");
|
|
|
|
Ok(None)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
deserializer.deserialize_any(StringOrVec)
|
|
|
|
}
|
|
|
|
|
2024-11-05 11:19:48 -05:00
|
|
|
#[derive(Debug, Eq, Clone)]
|
2022-12-10 13:15:32 -05:00
|
|
|
pub struct Tag {
|
|
|
|
pub name: String,
|
|
|
|
pub slug: String,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl<'de> Deserialize<'de> for Tag {
|
|
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
|
|
where
|
|
|
|
D: serde::Deserializer<'de>,
|
|
|
|
{
|
|
|
|
let name = String::deserialize(deserializer)?;
|
|
|
|
let slug = slugify(&name);
|
|
|
|
Ok(Tag { name, slug })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl PartialEq for Tag {
|
|
|
|
fn eq(&self, other: &Self) -> bool {
|
|
|
|
self.slug == other.slug
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Hash for Tag {
|
|
|
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
|
|
|
self.slug.hash(state);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-05 11:19:48 -05:00
|
|
|
pub trait PostContent: std::fmt::Debug + PartialEq {
|
2022-12-10 13:15:32 -05:00
|
|
|
fn to_html(self) -> HtmlContent;
|
|
|
|
fn word_count(&self) -> u32;
|
|
|
|
}
|
|
|
|
|
2024-11-05 11:19:48 -05:00
|
|
|
#[derive(Debug, PartialEq)]
|
2022-12-10 13:15:32 -05:00
|
|
|
pub enum AnyContent {
|
|
|
|
Markdown(MarkdownContent),
|
|
|
|
Html(HtmlContent),
|
|
|
|
}
|
|
|
|
|
|
|
|
impl PostContent for AnyContent {
|
|
|
|
fn to_html(self) -> HtmlContent {
|
|
|
|
match self {
|
|
|
|
AnyContent::Markdown(inner) => inner.to_html(),
|
|
|
|
AnyContent::Html(inner) => inner,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn word_count(&self) -> u32 {
|
|
|
|
match self {
|
|
|
|
AnyContent::Markdown(inner) => inner.word_count(),
|
|
|
|
AnyContent::Html(inner) => inner.word_count(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-05 11:19:48 -05:00
|
|
|
#[derive(Debug, PartialEq)]
|
2022-12-10 13:15:32 -05:00
|
|
|
pub struct MarkdownContent(String);
|
|
|
|
|
|
|
|
impl PostContent for MarkdownContent {
|
|
|
|
fn to_html(self) -> HtmlContent {
|
|
|
|
let mut buf = vec![];
|
|
|
|
markdown::render(&self.0, &mut buf);
|
|
|
|
HtmlContent(String::from_utf8(buf).unwrap())
|
|
|
|
}
|
|
|
|
|
|
|
|
fn word_count(&self) -> u32 {
|
|
|
|
word_count::markdown(&self.0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-05 11:19:48 -05:00
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
2022-12-10 13:15:32 -05:00
|
|
|
pub struct HtmlContent(String);
|
|
|
|
|
|
|
|
impl PostContent for HtmlContent {
|
|
|
|
fn to_html(self) -> HtmlContent {
|
|
|
|
self
|
|
|
|
}
|
|
|
|
|
|
|
|
fn word_count(&self) -> u32 {
|
|
|
|
word_count::html(&self.0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl HtmlContent {
|
|
|
|
pub fn html(&self) -> &str {
|
|
|
|
&self.0
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn content_from(extension: &str, content: &str) -> anyhow::Result<AnyContent> {
|
|
|
|
match extension {
|
|
|
|
"md" => Ok(AnyContent::Markdown(MarkdownContent(content.to_owned()))),
|
|
|
|
"html" => Ok(AnyContent::Html(HtmlContent(content.to_owned()))),
|
|
|
|
_ => Err(anyhow!("unknown extension {}", extension)),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
|
|
|
use chrono::{DateTime, FixedOffset, TimeZone};
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
struct DeserializedDate {
|
|
|
|
d: DateTime<FixedOffset>,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_deserialize_date() {
|
|
|
|
let deserialized: DeserializedDate =
|
|
|
|
toml::from_str(r#"d = "2017-02-17 14:30:42 -0400""#).unwrap();
|
|
|
|
let expected = FixedOffset::west(4 * 3600)
|
|
|
|
.ymd(2017, 2, 17)
|
|
|
|
.and_hms(14, 30, 42);
|
|
|
|
assert_eq!(deserialized.d, expected);
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_deserialize_old_permalink() {
|
|
|
|
let none: super::PostMetadata = toml::from_str(
|
|
|
|
r#"
|
|
|
|
title = "Mocking HTTP Requests for iOS App UI Tests"
|
|
|
|
date = "2019-12-22 19:12:42 -0400"
|
|
|
|
"#,
|
|
|
|
)
|
|
|
|
.unwrap();
|
|
|
|
assert_eq!(none.old_permalink, None);
|
|
|
|
let single: super::PostMetadata = toml::from_str(
|
|
|
|
r#"
|
|
|
|
title = "Mocking HTTP Requests for iOS App UI Tests"
|
|
|
|
date = "2019-12-22 19:12:42 -0400"
|
|
|
|
old_permalink = "/ios/2019/mock-http-ios-ui-testing/"
|
|
|
|
"#,
|
|
|
|
)
|
|
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
|
|
single.old_permalink,
|
|
|
|
Some(vec!["/ios/2019/mock-http-ios-ui-testing/".into()])
|
|
|
|
);
|
|
|
|
let multi: super::PostMetadata = toml::from_str(
|
|
|
|
r#"
|
|
|
|
title = "Mocking HTTP Requests for iOS App UI Tests"
|
|
|
|
date = "2019-12-22 19:12:42 -0400"
|
|
|
|
old_permalink = ["/ios/2019/mock-http-ios-ui-testing/", "something else"]
|
|
|
|
"#,
|
|
|
|
)
|
|
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
|
|
multi.old_permalink,
|
|
|
|
Some(vec![
|
|
|
|
"/ios/2019/mock-http-ios-ui-testing/".into(),
|
|
|
|
"something else".into()
|
|
|
|
])
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|