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 {
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);
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}}"));
}
}