defter 0.8.3

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! What the homepage says about defter: the beginning of the README, its
//! first two blocks (a paragraph and the list after it), 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 blocks (paragraphs, lists) the homepage shows.
const BLOCKS: usize = 2;

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

/// The first `count` blocks of `markdown`, headings left out, each as HTML: a
/// list (lines starting `* ` or `- `) as `<ul>`, anything else as `<p>`.
fn blocks(markdown: &str, count: usize) -> Vec<String> {
    let joined = |text: &str| inline(&text.split_whitespace().collect::<Vec<_>>().join(" "));
    markdown
        .split("\n\n")
        .map(str::trim)
        .filter(|block| !block.is_empty() && !block.starts_with('#'))
        .take(count)
        .map(|block| {
            let item = |line: &str| {
                line.strip_prefix("* ")
                    .or_else(|| line.strip_prefix("- "))
                    .map(str::to_string)
            };
            if block.lines().next().and_then(item).is_some() {
                // An item may go on over the next lines, indented or not.
                let mut items: Vec<String> = Vec::new();
                for line in block.lines().map(str::trim) {
                    match (item(line), items.last_mut()) {
                        (Some(text), _) => items.push(text),
                        (None, Some(last)) => {
                            last.push(' ');
                            last.push_str(line);
                        }
                        (None, None) => {}
                    }
                }
                let items: String = items
                    .iter()
                    .map(|text| format!("<li>{}</li>", joined(text)))
                    .collect();
                format!("<ul>{items}</ul>")
            } else {
                format!("<p>{}</p>", joined(block))
            }
        })
        .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_blocks_become_html() {
        let markdown = "# defter\n\nA **self-hosted** server: see [Zotero](https://www.zotero.org/)\n\
                        and `commonmeta sync` <script>:\n\n* updates `one`,\n* fetches\n  two.\n\nThird.";
        assert_eq!(
            blocks(markdown, 2),
            [
                "<p>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;:</p>",
                "<ul><li>updates <code>one</code>,</li><li>fetches two.</li></ul>",
            ]
        );
        // Not a web link: as written.
        assert_eq!(inline("[a](javascript:x)"), "[a](javascript:x)");
    }

    /// The README begins with a paragraph and a list of what defter does.
    #[test]
    fn the_readme_begins_with_a_paragraph_and_a_list() {
        assert_eq!(INTRO.len(), 2);
        assert!(
            INTRO[0].starts_with("<p>") && INTRO[0].contains("Zotero Storage"),
            "{}",
            INTRO[0]
        );
        assert!(INTRO[1].starts_with("<ul><li>"), "{}", INTRO[1]);
        assert_eq!(INTRO[1].matches("<li>").count(), 3, "{}", INTRO[1]);
    }
}