texted 1.2.1

A markdown blog platform that lets you in control of your data
Documentation
use std::io::Cursor;
use std::sync::Arc;

use chrono::{TimeZone, Utc};
use quick_xml::events::{BytesCData, BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer;

use crate::content::{Content, PostId};

/* Example
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">

<channel>
  <title>Thiago Cafe blog posts</title>
  <link>https://thiagocafe.com</link>
  <description>This blog is about programming and other technological things. Written by someone developing software for fun and professionally for longer than I want to admit and in more programming languages that I can remember</description>
  <item>
    <title>Creating a daemon in System D</title>
    <link>https://thiagocafe.com/view/20240216_creating_a_daemon_in_systemd</link>
    <description>So, you created your awesome server-side application and you are ready to start using</description>
  </item>
  <item>
    <title>What I learned after 20+ years of software development</title>
    <link>https://thiagocafe.com/view/20220402_what_i_learned</link>
    <description>How to be a great software engineer?</description>
  </item>
</channel>

</rss>
*/

pub struct RssChannel<'a> {
    pub ch_title: &'a str,
    pub ch_link: &'a str,
    pub ch_desc: &'a str,
}


impl<'a> RssChannel<'a> {
    pub fn render(&self, contents: &[Arc<Content>]) -> quick_xml::Result<Vec<u8>> {
        let mut writer = Writer::new(Cursor::new(Vec::new()));

        // <?xml version="1.0" encoding="UTF-8" ?>
        let decl = Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None));
        writer.write_event(decl)?;

        // <rss version="2.0">
        let mut rss = BytesStart::new("rss");
        rss.push_attribute(("version", "2.0"));
        writer.write_event(Event::Start(rss))?;

        // <channel>
        writer.write_event(Event::Start(BytesStart::new("channel")))?;

        // <title>Thiago Cafe blog posts</title>
        push_text(&mut writer, "title", self.ch_title)?;

        // <link>https://thiagocafe.com</link>
        push_text(&mut writer, "link", self.ch_link)?;

        // <description>This blog is about programming and other technological things. Written by someone developing software for fun and professionally for longer than I want to admit and in more programming languages that I can remember</description>
        push_text(&mut writer, "description", self.ch_desc)?;

        for content in contents {
            // <item>
            writer.write_event(Event::Start(BytesStart::new("item")))?;

            // <title>What I learned after 20+ years of software development</title>
            let title = content.title.as_str();
            push_text(&mut writer, "title", title)?;

            // <link>https://thiagocafe.com/view/20220402_what_i_learned</link>
            let link = full_link(self.ch_link, content.link.as_str());
            push_text(&mut writer, "link", link.as_str())?;

            // <guid isPermaLink="false">https://thiagocafe.com/view/20220402_what_i_learned</guid>
            let PostId(ref guid) = content.header.id;
            let mut guid_elem = BytesStart::new("guid");
            guid_elem.push_attribute(("isPermaLink", "false"));
            writer.write_event(Event::Start(guid_elem))?;
            writer.write_event(Event::Text(BytesText::new(guid.as_str())))?;
            writer.write_event(Event::End(BytesEnd::new("guid")))?;

            // <description>How to be a great software engineer?</description>
            let description = content.rendered.as_str();
            push_cdata(&mut writer, "description", description)?;

            // <pubDate>Wed, 20 Apr 2022 16:00:00 +0200</pubDate>
            let dt = &content.header.date;
            let dt = TimeZone::from_utc_datetime(Utc::now().offset(), dt);
            push_text(&mut writer, "pubDate", &dt.to_rfc2822())?;


            // </item>
            writer.write_event(Event::End(BytesEnd::new("item")))?;
        }

        // </channel>
        writer.write_event(Event::End(BytesEnd::new("channel")))?;
        // </rss>
        writer.write_event(Event::End(BytesEnd::new("rss")))?;

        Ok(writer.into_inner().into_inner())
    }
}

fn full_link(base_url: &str, link: &str) -> String {
    let base_url = if base_url.ends_with('/') {
        base_url.to_string()
    } else {
        format!("{}/", base_url)
    };

    let link = if link.ends_with('/') {
        link.to_string()
    } else {
        format!("{}/", link)
    };

    format!("{}view/{}", base_url, link)
}

fn push_text(writer: &mut Writer<Cursor<Vec<u8>>>, tag: &str, text: &str) -> quick_xml::Result<()> {
    writer.write_event(Event::Start(BytesStart::new(tag)))?;
    writer.write_event(Event::Text(BytesText::new(text)))?;
    writer.write_event(Event::End(BytesEnd::new(tag)))?;
    Ok(())
}

fn push_cdata(writer: &mut Writer<Cursor<Vec<u8>>>, tag: &str, text: &str) -> quick_xml::Result<()> {
    writer.write_event(Event::Start(BytesStart::new(tag)))?;
    if text.contains("]]>") {
        let new_text = text.replace("]]>", "]] >");
        writer.write_event(Event::CData(BytesCData::new(&new_text)))?;
    } else {
        writer.write_event(Event::CData(BytesCData::new(text)))?;
    }
    writer.write_event(Event::End(BytesEnd::new(tag)))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::str;
    use std::sync::Arc;

    use chrono::{NaiveDate, NaiveDateTime, NaiveTime};

    use crate::content::{Content, ContentHeader, PostId};

    use super::*;

    fn create_cont(id: &str) -> Arc<Content> {
        let dt = NaiveDateTime::new(
            NaiveDate::from_ymd_opt(2024, 01, 02).unwrap(),
            NaiveTime::from_hms_opt(5, 6, 7).unwrap(),
        );
        let content = Content {
            header: ContentHeader {
                file_name: PathBuf::from(format!("post-{}.md", id)),
                id: PostId(id.to_string()),
                date: dt,
                author: "Thiago".to_string(),
                tags: vec![format!("first-tag-{}", id), format!("second-tag-{}", id)],
            },
            link: format!("post-{}", id),
            title: format!("title-of-post-{}", id),
            rendered: format!("summary-of-post-{}", id),
        };

        Arc::new(content)
    }

    #[test]
    fn render_xml() {
        let contents = vec![create_cont("1"), create_cont("2")];

        let ch_title = "my feed";
        let ch_link = "https://thiagocafe.com";
        let ch_desc = "My blog feed";
        let rss = RssChannel {
            ch_title,
            ch_link,
            ch_desc,
        };
        let xml = rss.render(&contents).unwrap();
        assert_eq!(str::from_utf8(&xml).unwrap(), EXPECTED);
    }

    const EXPECTED: &str = r##"<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>my feed</title><link>https://thiagocafe.com</link><description>My blog feed</description><item><title>title-of-post-1</title><link>https://thiagocafe.com/view/post-1/</link><guid isPermaLink="false">1</guid><description><![CDATA[summary-of-post-1]]></description><pubDate>Tue, 2 Jan 2024 05:06:07 +0000</pubDate></item><item><title>title-of-post-2</title><link>https://thiagocafe.com/view/post-2/</link><guid isPermaLink="false">2</guid><description><![CDATA[summary-of-post-2]]></description><pubDate>Tue, 2 Jan 2024 05:06:07 +0000</pubDate></item></channel></rss>"##;
}