bim 0.1.0

A content-first static site generator for blogs, digital gardens, and knowledge bases
Documentation
use crate::collections::Collection;
use crate::content::{Backlink, Page, Snippet};
use crate::lang::TextDirection;
use std::collections::HashMap;
use std::path::Path;

pub struct LayoutEngine {
    layouts: HashMap<String, String>,
    default_layout: String,
}

impl LayoutEngine {
    pub fn load(layouts_dir: &Path) -> Self {
        let mut layouts = HashMap::new();
        let default_layout = DEFAULT_LAYOUT.to_string();

        if layouts_dir.exists() {
            if let Ok(entries) = std::fs::read_dir(layouts_dir) {
                for entry in entries.flatten() {
                    let path = entry.path();
                    if path.extension().and_then(|e| e.to_str()) == Some("html") {
                        if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
                            if let Ok(content) = std::fs::read_to_string(&path) {
                                layouts.insert(name.to_string(), content);
                            }
                        }
                    }
                }
            }
        }

        Self {
            layouts,
            default_layout,
        }
    }

    pub fn render_page(&self, page: &Page, nav_html: &str) -> String {
        // Use default layout for auto-generated index pages
        let layout = if page.is_auto_index {
            self.get_default_layout()
        } else {
            self.get_layout(page.collection)
        };
        self.apply_layout(
            layout,
            &page.title,
            &page.html_content,
            nav_html,
            &page.backlinks,
            page.date.map(|d| d.format("%Y-%m-%d").to_string()),
            &page.lang,
            page.dir,
        )
    }

    pub fn render_snippet(&self, snippet: &Snippet, nav_html: &str) -> String {
        let layout = self.get_layout(Collection::Snippets);

        // Wrap snippet content with title and code
        let content = format!(
            "<h1>{}</h1>\n<div class=\"snippet-code\">\n{}\n</div>",
            html_escape::encode_text(&snippet.title),
            snippet.html_content
        );

        self.apply_layout(
            layout,
            &snippet.title,
            &content,
            nav_html,
            &snippet.backlinks,
            snippet.date.map(|d| d.format("%Y-%m-%d").to_string()),
            "en",
            TextDirection::Ltr,
        )
    }

    fn get_layout(&self, collection: Collection) -> &str {
        let layout_name = collection.layout_name();

        self.layouts
            .get(layout_name)
            .or_else(|| self.layouts.get("default"))
            .map(|s| s.as_str())
            .unwrap_or(&self.default_layout)
    }

    fn get_default_layout(&self) -> &str {
        self.layouts
            .get("default")
            .map(|s| s.as_str())
            .unwrap_or(&self.default_layout)
    }

    #[allow(clippy::too_many_arguments)]
    fn apply_layout(
        &self,
        layout: &str,
        title: &str,
        content: &str,
        nav_html: &str,
        backlinks: &[Backlink],
        last_modified: Option<String>,
        lang: &str,
        dir: TextDirection,
    ) -> String {
        let backlinks_html = render_backlinks(backlinks);
        let last_modified_str = last_modified.unwrap_or_default();

        layout
            .replace("{{title}}", &html_escape::encode_text(title))
            .replace("{{content}}", content)
            .replace("{{nav}}", nav_html)
            .replace("{{backlinks}}", &backlinks_html)
            .replace("{{last_modified}}", &last_modified_str)
            .replace("{{lang}}", lang)
            .replace("{{dir}}", dir.as_str())
    }
}

fn render_backlinks(backlinks: &[Backlink]) -> String {
    if backlinks.is_empty() {
        return String::new();
    }

    let mut html = String::from("<section class=\"backlinks\">\n<h2>Backlinks</h2>\n<ul>\n");

    for backlink in backlinks {
        html.push_str(&format!(
            "  <li><a href=\"{}\">{}</a></li>\n",
            html_escape::encode_double_quoted_attribute(&backlink.url),
            html_escape::encode_text(&backlink.title)
        ));
    }

    html.push_str("</ul>\n</section>");
    html
}

const DEFAULT_LAYOUT: &str = r#"<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{title}}</title>
    <meta name="generator" content="bim">
    <link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/feed.xml">
    <style>
        :root {
            --bg: #ffffff;
            --fg: #222222;
            --accent: #005f87;
            --muted: #555555;
            --border: #cccccc;
            --code-bg: #f4f4f4;
        }
        @media (prefers-color-scheme: dark) {
            :root {
                --bg: #222222;
                --fg: #dedede;
                --accent: #87afd7;
                --muted: #999999;
                --border: #444444;
                --code-bg: #333333;
            }
        }
        * { box-sizing: border-box; margin: 0; padding: 0; }
        html { font-size: 16px; }
        body {
            font-family: Menlo, Monaco, 'Courier New', monospace;
            font-size: 0.875rem;
            line-height: 1.6;
            max-width: 680px;
            margin: 0 auto;
            padding: 1.5rem 1rem;
            background: var(--bg);
            color: var(--fg);
        }
        header { margin-bottom: 2rem; }
        .site-title {
            font-size: 1rem;
            font-weight: bold;
            text-decoration: none;
            color: var(--fg);
        }
        .site-title:hover { color: var(--accent); }
        hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
        a { color: var(--accent); text-decoration: none; }
        a:hover { text-decoration: underline; }
        nav { margin-top: 1rem; }
        nav ul { list-style: none; }
        nav li { margin: 0.25rem 0; }
        nav li::before { content: "- "; color: var(--muted); }
        main { min-height: 50vh; }
        h1, h2, h3, h4, h5, h6 { font-weight: bold; line-height: 1.4; margin: 1.5rem 0 0.5rem; }
        h1 { font-size: 1.25rem; margin-top: 0; }
        h2 { font-size: 1.1rem; }
        h3, h4, h5, h6 { font-size: 1rem; }
        p { margin: 0.75rem 0; }
        ul, ol { margin: 0.75rem 0; padding-left: 1.5rem; }
        li { margin: 0.25rem 0; }
        code {
            font-family: inherit;
            background: var(--code-bg);
            padding: 0.1em 0.3em;
            font-size: 0.95em;
            unicode-bidi: embed;
        }
        pre {
            background: var(--code-bg);
            padding: 0.75rem;
            overflow-x: auto;
            margin: 1rem 0;
            border: 1px solid var(--border);
            direction: ltr;
            text-align: left;
            unicode-bidi: embed;
        }
        pre code { background: none; padding: 0; }
        blockquote {
            border-left: 2px solid var(--muted);
            margin: 1rem 0;
            padding-left: 1rem;
            color: var(--muted);
        }
        img { max-width: 100%; height: auto; }
        table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: 0.85rem; }
        th, td { border: 1px solid var(--border); padding: 0.4rem 0.6rem; text-align: left; }
        th { background: var(--code-bg); font-weight: bold; }
        .backlinks {
            margin-top: 2rem;
            padding-top: 1rem;
            border-top: 1px solid var(--border);
        }
        .backlinks h2 {
            font-size: 0.875rem;
            color: var(--muted);
            font-weight: normal;
            margin-bottom: 0.5rem;
        }
        .backlinks ul { list-style: none; padding-left: 0; }
        .backlinks li::before { content: "← "; color: var(--muted); }
        time, .date { color: var(--muted); }
        footer {
            margin-top: 2rem;
            padding-top: 1rem;
            border-top: 1px solid var(--border);
            color: var(--muted);
            font-size: 0.8rem;
        }
        .recent-list, .collection-list { list-style: none; padding-left: 0; }
        .recent-list li, .collection-list li {
            display: flex;
            justify-content: space-between;
            padding: 0.35rem 0;
            border-bottom: 1px dotted var(--border);
        }
        .recent-list li:last-child, .collection-list li:last-child { border-bottom: none; }
        .tag-list, .context-list { list-style: none; padding-left: 0; }
        .tag-list li, .context-list li { display: inline-block; margin: 0.25rem 0.5rem 0.25rem 0; }
        .tag-list li::before { content: ""; }
        .context-list li::before { content: ""; }
        .count { color: var(--muted); font-size: 0.85em; }
        /* RTL support - only for article content */
        article[dir="rtl"] { text-align: right; }
        article[dir="rtl"] ul, article[dir="rtl"] ol { padding-left: 0; padding-right: 1.5rem; }
        article[dir="rtl"] blockquote { border-left: none; border-right: 2px solid var(--muted); padding-left: 0; padding-right: 1rem; }
        /* Bidi isolation for mixed content */
        article { unicode-bidi: isolate; }
        @media (max-width: 600px) {
            body { padding: 1rem 0.75rem; }
        }
    </style>
</head>
<body>
    <header>
        <a href="/" class="site-title">~</a>
        {{nav}}
    </header>
    <hr>
    <article lang="{{lang}}" dir="{{dir}}">
        {{content}}
    </article>
    {{backlinks}}
    <hr>
    <footer>
        <p>Last modified: {{last_modified}} · Built with <a href="https://bim.gumx.cc">bim</a></p>
    </footer>
</body>
</html>
"#;

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

    #[test]
    fn test_render_backlinks_empty() {
        let html = render_backlinks(&[]);
        assert!(html.is_empty());
    }

    #[test]
    fn test_render_backlinks() {
        let backlinks = vec![Backlink {
            url: "/blog/post/".to_string(),
            title: "My Post".to_string(),
        }];
        let html = render_backlinks(&backlinks);
        assert!(html.contains("Backlinks"));
        assert!(html.contains("/blog/post/"));
        assert!(html.contains("My Post"));
    }

    #[test]
    fn test_layout_engine_default() {
        let engine = LayoutEngine::load(Path::new("/nonexistent"));
        let layout = engine.get_layout(Collection::Blog);
        assert!(layout.contains("{{title}}"));
        assert!(layout.contains("{{content}}"));
    }
}