defter 0.8.0

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! What the homepage says about defter: the first two paragraphs of the
//! README, built in, with the little Markdown they use as HTML.

use std::sync::LazyLock;

const README: &str = include_str!("../README.md");

/// How many of the README's paragraphs the homepage shows.
const PARAGRAPHS: usize = 2;

/// The README's first paragraphs, after its title, as HTML.
pub static INTRO: LazyLock<Vec<String>> = LazyLock::new(|| paragraphs(README, PARAGRAPHS));

/// The first `count` paragraphs of `markdown`, headings left out, each as HTML.
fn paragraphs(markdown: &str, count: usize) -> Vec<String> {
    markdown
        .split("\n\n")
        .map(str::trim)
        .filter(|block| !block.is_empty() && !block.starts_with('#'))
        .take(count)
        .map(|block| inline(&block.split_whitespace().collect::<Vec<_>>().join(" ")))
        .collect()
}

/// The inline Markdown of one paragraph as HTML, its text escaped: `**bold**`,
/// `` `code` `` and `[links](https://…)`. Anything else stays as written.
fn inline(text: &str) -> String {
    let mut out = String::new();
    let mut rest = text;
    while let Some(c) = rest.chars().next() {
        if let Some(after) = rest.strip_prefix("**")
            && let Some((inner, tail)) = after.split_once("**")
        {
            out.push_str(&format!("<strong>{}</strong>", inline(inner)));
            rest = tail;
        } else if let Some(after) = rest.strip_prefix('`')
            && let Some((inner, tail)) = after.split_once('`')
        {
            out.push_str(&format!("<code>{}</code>", escape(inner)));
            rest = tail;
        } else if let Some((label, url, tail)) = link(rest) {
            out.push_str(&format!(
                r#"<a href="{}" class="underline" rel="noreferrer">{}</a>"#,
                escape(url),
                inline(label)
            ));
            rest = tail;
        } else {
            out.push_str(&escape(&c.to_string()));
            rest = &rest[c.len_utf8()..];
        }
    }
    out
}

/// `[label](url)` at the start of `text`, for a web URL: label, URL and the rest.
fn link(text: &str) -> Option<(&str, &str, &str)> {
    let (label, after) = text.strip_prefix('[')?.split_once("](")?;
    let (url, tail) = after.split_once(')')?;
    (url.starts_with("https://") || url.starts_with("http://")).then_some((label, url, tail))
}

fn escape(text: &str) -> String {
    text.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_first_paragraphs_become_html() {
        let markdown = "# defter\n\nA **self-hosted** server: see [Zotero](https://www.zotero.org/)\n\
                        and `commonmeta sync` <script>.\n\nSecond `one`.\n\nThird.";
        assert_eq!(
            paragraphs(markdown, 2),
            [
                "A <strong>self-hosted</strong> server: see <a href=\"https://www.zotero.org/\" \
                 class=\"underline\" rel=\"noreferrer\">Zotero</a> and <code>commonmeta sync</code> \
                 &lt;script&gt;.",
                "Second <code>one</code>.",
            ]
        );
        // Not a web link: as written.
        assert_eq!(inline("[a](javascript:x)"), "[a](javascript:x)");
    }

    #[test]
    fn the_readme_has_two_paragraphs_to_show() {
        assert_eq!(INTRO.len(), 2);
        assert!(INTRO[0].contains("Zotero"), "{}", INTRO[0]);
    }
}