feat(templates): complete Tera migration, remove maud
Fully migrate from compile-time maud templates to runtime Tera: - Rewrote main.rs to use TemplateEngine and discover_sections() - Replaced hardcoded blog/projects with generic section loop - Added Clone derive to Frontmatter and Content - Fixed section_type dispatch via Section struct - Deleted src/templates.rs, removed maud dependency Users can now add sections without code changes.
This commit is contained in:
46
Cargo.lock
generated
46
Cargo.lock
generated
@@ -816,28 +816,6 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "maud"
|
|
||||||
version = "0.26.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
|
|
||||||
dependencies = [
|
|
||||||
"itoa",
|
|
||||||
"maud_macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "maud_macros"
|
|
||||||
version = "0.26.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-error",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.114",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
@@ -868,7 +846,6 @@ dependencies = [
|
|||||||
"gray_matter",
|
"gray_matter",
|
||||||
"katex-rs",
|
"katex-rs",
|
||||||
"lightningcss",
|
"lightningcss",
|
||||||
"maud",
|
|
||||||
"mermaid-rs-renderer",
|
"mermaid-rs-renderer",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1163,29 +1140,6 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro-error"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-error-attr",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro-error-attr"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ version = "0.1.0"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gray_matter = "0.2"
|
gray_matter = "0.2"
|
||||||
maud = "0.26"
|
|
||||||
pulldown-cmark = "0.12"
|
pulldown-cmark = "0.12"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pub struct NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed frontmatter from a content file.
|
/// Parsed frontmatter from a content file.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Frontmatter {
|
pub struct Frontmatter {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
@@ -50,7 +50,7 @@ pub struct Frontmatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A content item ready for rendering.
|
/// A content item ready for rendering.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Content {
|
pub struct Content {
|
||||||
pub kind: ContentKind,
|
pub kind: ContentKind,
|
||||||
pub frontmatter: Frontmatter,
|
pub frontmatter: Frontmatter,
|
||||||
|
|||||||
202
src/main.rs
202
src/main.rs
@@ -12,10 +12,10 @@ mod math;
|
|||||||
mod mermaid;
|
mod mermaid;
|
||||||
mod render;
|
mod render;
|
||||||
mod template_engine;
|
mod template_engine;
|
||||||
mod templates;
|
|
||||||
|
|
||||||
use crate::content::{discover_nav, Content, ContentKind, NavItem};
|
use crate::content::{discover_nav, discover_sections, Content, ContentKind, NavItem};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::template_engine::{ContentContext, TemplateEngine};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ fn run() -> Result<()> {
|
|||||||
let output_dir = Path::new("public");
|
let output_dir = Path::new("public");
|
||||||
let static_dir = Path::new("static");
|
let static_dir = Path::new("static");
|
||||||
let config_path = Path::new("site.toml");
|
let config_path = Path::new("site.toml");
|
||||||
|
let template_dir = Path::new("templates");
|
||||||
|
|
||||||
if !content_dir.exists() {
|
if !content_dir.exists() {
|
||||||
return Err(Error::ContentDirNotFound(content_dir.to_path_buf()));
|
return Err(Error::ContentDirNotFound(content_dir.to_path_buf()));
|
||||||
@@ -39,99 +40,103 @@ fn run() -> Result<()> {
|
|||||||
// Load site configuration
|
// Load site configuration
|
||||||
let config = config::SiteConfig::load(config_path)?;
|
let config = config::SiteConfig::load(config_path)?;
|
||||||
|
|
||||||
|
// Load Tera templates
|
||||||
|
let engine = TemplateEngine::new(template_dir)?;
|
||||||
|
|
||||||
// Discover navigation from filesystem
|
// Discover navigation from filesystem
|
||||||
let nav = discover_nav(content_dir)?;
|
let nav = discover_nav(content_dir)?;
|
||||||
|
|
||||||
// 0. Copy static assets
|
// 0. Copy static assets
|
||||||
copy_static_assets(static_dir, output_dir)?;
|
copy_static_assets(static_dir, output_dir)?;
|
||||||
|
|
||||||
// 1. Process blog posts
|
// 1. Discover and process all sections
|
||||||
let mut posts = process_blog_posts(content_dir, output_dir, &config, &nav)?;
|
let sections = discover_sections(content_dir)?;
|
||||||
|
let mut all_posts = Vec::new(); // For feed generation
|
||||||
|
|
||||||
// 2. Generate blog index (sorted by date, newest first)
|
for section in §ions {
|
||||||
posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
|
eprintln!("processing section: {}", section.name);
|
||||||
generate_blog_index(output_dir, &posts, &config, &nav)?;
|
|
||||||
|
|
||||||
// 2b. Generate Atom feed
|
// Collect and sort items in this section
|
||||||
generate_feed(output_dir, &posts, &config, content_dir)?;
|
let mut items = section.collect_items()?;
|
||||||
|
|
||||||
// 3. Process standalone pages (discovered dynamically)
|
// Sort based on section type
|
||||||
process_pages(content_dir, output_dir, &config, &nav)?;
|
match section.section_type.as_str() {
|
||||||
|
"blog" => {
|
||||||
// 4. Process projects and generate project index
|
// Blog: sort by date, newest first
|
||||||
let mut projects = process_projects(content_dir)?;
|
items.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
|
||||||
projects.sort_by(|a, b| {
|
all_posts.extend(items.iter().cloned());
|
||||||
|
}
|
||||||
|
"projects" => {
|
||||||
|
// Projects: sort by weight
|
||||||
|
items.sort_by(|a, b| {
|
||||||
a.frontmatter
|
a.frontmatter
|
||||||
.weight
|
.weight
|
||||||
.unwrap_or(99)
|
.unwrap_or(99)
|
||||||
.cmp(&b.frontmatter.weight.unwrap_or(99))
|
.cmp(&b.frontmatter.weight.unwrap_or(99))
|
||||||
});
|
});
|
||||||
generate_projects_index(output_dir, &projects, &config, &nav)?;
|
}
|
||||||
|
_ => {
|
||||||
// 5. Generate homepage
|
// Default: sort by weight then title
|
||||||
generate_homepage(content_dir, output_dir, &config, &nav)?;
|
items.sort_by(|a, b| {
|
||||||
|
a.frontmatter
|
||||||
eprintln!("done!");
|
.weight
|
||||||
Ok(())
|
.unwrap_or(50)
|
||||||
}
|
.cmp(&b.frontmatter.weight.unwrap_or(50))
|
||||||
|
.then_with(|| a.frontmatter.title.cmp(&b.frontmatter.title))
|
||||||
/// Process all blog posts in content/blog/
|
});
|
||||||
fn process_blog_posts(
|
}
|
||||||
content_dir: &Path,
|
|
||||||
output_dir: &Path,
|
|
||||||
config: &config::SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Result<Vec<Content>> {
|
|
||||||
let blog_dir = content_dir.join("blog");
|
|
||||||
let mut posts = Vec::new();
|
|
||||||
|
|
||||||
for entry in walkdir::WalkDir::new(&blog_dir)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| {
|
|
||||||
e.path().extension().is_some_and(|ext| ext == "md")
|
|
||||||
&& e.path().file_name().is_some_and(|n| n != "_index.md")
|
|
||||||
})
|
|
||||||
{
|
|
||||||
let path = entry.path();
|
|
||||||
eprintln!("processing: {}", path.display());
|
|
||||||
|
|
||||||
let content = Content::from_path(path, ContentKind::Post)?;
|
|
||||||
let html_body = render::markdown_to_html(&content.body);
|
|
||||||
let page_path = format!("/{}", content.output_path(content_dir).display());
|
|
||||||
let page =
|
|
||||||
templates::render_post(&content.frontmatter, &html_body, &page_path, config, nav);
|
|
||||||
|
|
||||||
write_output(output_dir, content_dir, &content, page.into_string())?;
|
|
||||||
posts.push(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(posts)
|
// Render individual content pages (for blog posts)
|
||||||
}
|
if section.section_type == "blog" {
|
||||||
|
for item in &items {
|
||||||
|
eprintln!(" processing: {}", item.slug);
|
||||||
|
let html_body = render::markdown_to_html(&item.body);
|
||||||
|
let page_path = format!("/{}", item.output_path(content_dir).display());
|
||||||
|
let html = engine.render_content(item, &html_body, &page_path, &config, &nav)?;
|
||||||
|
write_output(output_dir, content_dir, item, html)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate the blog listing page
|
// Render section index
|
||||||
fn generate_blog_index(
|
let page_path = format!("/{}/index.html", section.name);
|
||||||
output_dir: &Path,
|
let item_contexts: Vec<_> = items
|
||||||
posts: &[Content],
|
.iter()
|
||||||
config: &config::SiteConfig,
|
.map(|c| ContentContext::from_content(c, content_dir))
|
||||||
nav: &[NavItem],
|
.collect();
|
||||||
) -> Result<()> {
|
let html = engine.render_section(
|
||||||
let out_path = output_dir.join("blog/index.html");
|
§ion.index,
|
||||||
eprintln!("generating: {}", out_path.display());
|
§ion.section_type,
|
||||||
|
&item_contexts,
|
||||||
let page = templates::render_blog_index("Blog", posts, "/blog/index.html", config, nav);
|
&page_path,
|
||||||
|
&config,
|
||||||
|
&nav,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let out_path = output_dir.join(§ion.name).join("index.html");
|
||||||
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
|
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
|
||||||
path: out_path.parent().unwrap().to_path_buf(),
|
path: out_path.parent().unwrap().to_path_buf(),
|
||||||
source: e,
|
source: e,
|
||||||
})?;
|
})?;
|
||||||
|
fs::write(&out_path, html).map_err(|e| Error::WriteFile {
|
||||||
fs::write(&out_path, page.into_string()).map_err(|e| Error::WriteFile {
|
|
||||||
path: out_path.clone(),
|
path: out_path.clone(),
|
||||||
source: e,
|
source: e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
eprintln!(" → {}", out_path.display());
|
eprintln!(" → {}", out_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate Atom feed (blog posts only)
|
||||||
|
if !all_posts.is_empty() {
|
||||||
|
generate_feed(output_dir, &all_posts, &config, content_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process standalone pages (discovered dynamically)
|
||||||
|
process_pages(content_dir, output_dir, &config, &nav, &engine)?;
|
||||||
|
|
||||||
|
// 4. Generate homepage
|
||||||
|
generate_homepage(content_dir, output_dir, &config, &nav, &engine)?;
|
||||||
|
|
||||||
|
eprintln!("done!");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +167,7 @@ fn process_pages(
|
|||||||
output_dir: &Path,
|
output_dir: &Path,
|
||||||
config: &config::SiteConfig,
|
config: &config::SiteConfig,
|
||||||
nav: &[NavItem],
|
nav: &[NavItem],
|
||||||
|
engine: &TemplateEngine,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Dynamically discover top-level .md files (except _index.md)
|
// Dynamically discover top-level .md files (except _index.md)
|
||||||
let entries = fs::read_dir(content_dir).map_err(|e| Error::ReadFile {
|
let entries = fs::read_dir(content_dir).map_err(|e| Error::ReadFile {
|
||||||
@@ -180,76 +186,28 @@ fn process_pages(
|
|||||||
let content = Content::from_path(&path, ContentKind::Page)?;
|
let content = Content::from_path(&path, ContentKind::Page)?;
|
||||||
let html_body = render::markdown_to_html(&content.body);
|
let html_body = render::markdown_to_html(&content.body);
|
||||||
let page_path = format!("/{}", content.output_path(content_dir).display());
|
let page_path = format!("/{}", content.output_path(content_dir).display());
|
||||||
let page =
|
let html = engine.render_page(&content, &html_body, &page_path, config, nav)?;
|
||||||
templates::render_page(&content.frontmatter, &html_body, &page_path, config, nav);
|
|
||||||
|
|
||||||
write_output(output_dir, content_dir, &content, page.into_string())?;
|
write_output(output_dir, content_dir, &content, html)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all project cards (without writing individual pages)
|
|
||||||
fn process_projects(content_dir: &Path) -> Result<Vec<Content>> {
|
|
||||||
let projects_dir = content_dir.join("projects");
|
|
||||||
let mut projects = Vec::new();
|
|
||||||
|
|
||||||
for entry in walkdir::WalkDir::new(&projects_dir)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| {
|
|
||||||
e.path().extension().is_some_and(|ext| ext == "md")
|
|
||||||
&& e.path().file_name().is_some_and(|n| n != "_index.md")
|
|
||||||
})
|
|
||||||
{
|
|
||||||
let content = Content::from_path(entry.path(), ContentKind::Project)?;
|
|
||||||
projects.push(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(projects)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate the projects listing page
|
|
||||||
fn generate_projects_index(
|
|
||||||
output_dir: &Path,
|
|
||||||
projects: &[Content],
|
|
||||||
config: &config::SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Result<()> {
|
|
||||||
let out_path = output_dir.join("projects/index.html");
|
|
||||||
eprintln!("generating: {}", out_path.display());
|
|
||||||
|
|
||||||
let page =
|
|
||||||
templates::render_projects_index("Projects", projects, "/projects/index.html", config, nav);
|
|
||||||
|
|
||||||
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
|
|
||||||
path: out_path.parent().unwrap().to_path_buf(),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
fs::write(&out_path, page.into_string()).map_err(|e| Error::WriteFile {
|
|
||||||
path: out_path.clone(),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
eprintln!(" → {}", out_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate the homepage from content/_index.md
|
/// Generate the homepage from content/_index.md
|
||||||
fn generate_homepage(
|
fn generate_homepage(
|
||||||
content_dir: &Path,
|
content_dir: &Path,
|
||||||
output_dir: &Path,
|
output_dir: &Path,
|
||||||
config: &config::SiteConfig,
|
config: &config::SiteConfig,
|
||||||
nav: &[NavItem],
|
nav: &[NavItem],
|
||||||
|
engine: &TemplateEngine,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let index_path = content_dir.join("_index.md");
|
let index_path = content_dir.join("_index.md");
|
||||||
eprintln!("generating: homepage");
|
eprintln!("generating: homepage");
|
||||||
|
|
||||||
let content = Content::from_path(&index_path, ContentKind::Section)?;
|
let content = Content::from_path(&index_path, ContentKind::Section)?;
|
||||||
let html_body = render::markdown_to_html(&content.body);
|
let html_body = render::markdown_to_html(&content.body);
|
||||||
let page =
|
let html = engine.render_page(&content, &html_body, "/index.html", config, nav)?;
|
||||||
templates::render_homepage(&content.frontmatter, &html_body, "/index.html", config, nav);
|
|
||||||
|
|
||||||
let out_path = output_dir.join("index.html");
|
let out_path = output_dir.join("index.html");
|
||||||
|
|
||||||
@@ -258,7 +216,7 @@ fn generate_homepage(
|
|||||||
source: e,
|
source: e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
fs::write(&out_path, page.into_string()).map_err(|e| Error::WriteFile {
|
fs::write(&out_path, html).map_err(|e| Error::WriteFile {
|
||||||
path: out_path.clone(),
|
path: out_path.clone(),
|
||||||
source: e,
|
source: e,
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ impl TemplateEngine {
|
|||||||
nav: &[NavItem],
|
nav: &[NavItem],
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let mut ctx = self.base_context(page_path, config, nav);
|
let mut ctx = self.base_context(page_path, config, nav);
|
||||||
|
ctx.insert("title", &content.frontmatter.title);
|
||||||
ctx.insert("page", &FrontmatterContext::from(&content.frontmatter));
|
ctx.insert("page", &FrontmatterContext::from(&content.frontmatter));
|
||||||
ctx.insert("content", html_body);
|
ctx.insert("content", html_body);
|
||||||
self.render("page.html", &ctx)
|
self.render("page.html", &ctx)
|
||||||
@@ -64,6 +65,7 @@ impl TemplateEngine {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("content/default.html");
|
.unwrap_or("content/default.html");
|
||||||
let mut ctx = self.base_context(page_path, config, nav);
|
let mut ctx = self.base_context(page_path, config, nav);
|
||||||
|
ctx.insert("title", &content.frontmatter.title);
|
||||||
ctx.insert("page", &FrontmatterContext::from(&content.frontmatter));
|
ctx.insert("page", &FrontmatterContext::from(&content.frontmatter));
|
||||||
ctx.insert("content", html_body);
|
ctx.insert("content", html_body);
|
||||||
self.render(template, &ctx)
|
self.render(template, &ctx)
|
||||||
@@ -73,19 +75,16 @@ impl TemplateEngine {
|
|||||||
pub fn render_section(
|
pub fn render_section(
|
||||||
&self,
|
&self,
|
||||||
section: &Content,
|
section: &Content,
|
||||||
|
section_type: &str,
|
||||||
items: &[ContentContext],
|
items: &[ContentContext],
|
||||||
page_path: &str,
|
page_path: &str,
|
||||||
config: &SiteConfig,
|
config: &SiteConfig,
|
||||||
nav: &[NavItem],
|
nav: &[NavItem],
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let section_type = section
|
|
||||||
.frontmatter
|
|
||||||
.section_type
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("default");
|
|
||||||
let template = format!("section/{}.html", section_type);
|
let template = format!("section/{}.html", section_type);
|
||||||
|
|
||||||
let mut ctx = self.base_context(page_path, config, nav);
|
let mut ctx = self.base_context(page_path, config, nav);
|
||||||
|
ctx.insert("title", §ion.frontmatter.title);
|
||||||
ctx.insert("section", &FrontmatterContext::from(§ion.frontmatter));
|
ctx.insert("section", &FrontmatterContext::from(§ion.frontmatter));
|
||||||
ctx.insert("items", items);
|
ctx.insert("items", items);
|
||||||
self.render(&template, &ctx)
|
self.render(&template, &ctx)
|
||||||
@@ -98,6 +97,8 @@ impl TemplateEngine {
|
|||||||
ctx.insert("nav", nav);
|
ctx.insert("nav", nav);
|
||||||
ctx.insert("page_path", page_path);
|
ctx.insert("page_path", page_path);
|
||||||
ctx.insert("prefix", &relative_prefix(page_path));
|
ctx.insert("prefix", &relative_prefix(page_path));
|
||||||
|
// Trimmed base_url for canonical links
|
||||||
|
ctx.insert("base_url", config.base_url.trim_end_matches('/'));
|
||||||
ctx
|
ctx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
247
src/templates.rs
247
src/templates.rs
@@ -1,247 +0,0 @@
|
|||||||
//! HTML templates using maud.
|
|
||||||
|
|
||||||
use crate::config::SiteConfig;
|
|
||||||
use crate::content::{Content, Frontmatter, NavItem};
|
|
||||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
|
||||||
|
|
||||||
/// Compute relative path prefix based on page depth.
|
|
||||||
/// depth=0 (root) → "."
|
|
||||||
/// depth=1 (one level deep) → ".."
|
|
||||||
/// depth=2 → "../.."
|
|
||||||
fn relative_prefix(depth: usize) -> String {
|
|
||||||
if depth == 0 {
|
|
||||||
".".to_string()
|
|
||||||
} else {
|
|
||||||
(0..depth).map(|_| "..").collect::<Vec<_>>().join("/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate directory depth from page path.
|
|
||||||
/// "/index.html" → 0
|
|
||||||
/// "/about.html" → 0
|
|
||||||
/// "/blog/index.html" → 1
|
|
||||||
/// "/blog/slug.html" → 1
|
|
||||||
fn path_depth(page_path: &str) -> usize {
|
|
||||||
// Count segments: split by '/', filter empties, subtract 1 for filename
|
|
||||||
let segments: Vec<_> = page_path.split('/').filter(|s| !s.is_empty()).collect();
|
|
||||||
segments.len().saturating_sub(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a blog post with the base layout.
|
|
||||||
pub fn render_post(
|
|
||||||
frontmatter: &Frontmatter,
|
|
||||||
content_html: &str,
|
|
||||||
page_path: &str,
|
|
||||||
config: &SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Markup {
|
|
||||||
let depth = path_depth(page_path);
|
|
||||||
let prefix = relative_prefix(depth);
|
|
||||||
base_layout(
|
|
||||||
&frontmatter.title,
|
|
||||||
html! {
|
|
||||||
article.post {
|
|
||||||
header {
|
|
||||||
h1 { (frontmatter.title) }
|
|
||||||
@if let Some(ref date) = frontmatter.date {
|
|
||||||
time.date { (date) }
|
|
||||||
}
|
|
||||||
@if let Some(ref desc) = frontmatter.description {
|
|
||||||
p.description { (desc) }
|
|
||||||
}
|
|
||||||
@if !frontmatter.tags.is_empty() {
|
|
||||||
ul.tags {
|
|
||||||
@for tag in &frontmatter.tags {
|
|
||||||
li { a href=(format!("{}/tags/{}.html", prefix, tag)) { (tag) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
section.content {
|
|
||||||
(PreEscaped(content_html))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
page_path,
|
|
||||||
config,
|
|
||||||
nav,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a standalone page (about, collab, etc.)
|
|
||||||
pub fn render_page(
|
|
||||||
frontmatter: &Frontmatter,
|
|
||||||
content_html: &str,
|
|
||||||
page_path: &str,
|
|
||||||
config: &SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Markup {
|
|
||||||
base_layout(
|
|
||||||
&frontmatter.title,
|
|
||||||
html! {
|
|
||||||
article.page {
|
|
||||||
h1 { (frontmatter.title) }
|
|
||||||
section.content {
|
|
||||||
(PreEscaped(content_html))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
page_path,
|
|
||||||
config,
|
|
||||||
nav,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the homepage.
|
|
||||||
pub fn render_homepage(
|
|
||||||
frontmatter: &Frontmatter,
|
|
||||||
content_html: &str,
|
|
||||||
page_path: &str,
|
|
||||||
config: &SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Markup {
|
|
||||||
base_layout(
|
|
||||||
&frontmatter.title,
|
|
||||||
html! {
|
|
||||||
section.hero {
|
|
||||||
h1 { (frontmatter.title) }
|
|
||||||
@if let Some(ref desc) = frontmatter.description {
|
|
||||||
p.tagline { (desc) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
section.content {
|
|
||||||
(PreEscaped(content_html))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
page_path,
|
|
||||||
config,
|
|
||||||
nav,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the blog listing page.
|
|
||||||
pub fn render_blog_index(
|
|
||||||
title: &str,
|
|
||||||
posts: &[Content],
|
|
||||||
page_path: &str,
|
|
||||||
config: &SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Markup {
|
|
||||||
base_layout(
|
|
||||||
title,
|
|
||||||
html! {
|
|
||||||
h1 { (title) }
|
|
||||||
ul.post-list {
|
|
||||||
@for post in posts {
|
|
||||||
li {
|
|
||||||
// Posts are .html files in the same directory
|
|
||||||
a href=(format!("./{}.html", post.slug)) {
|
|
||||||
span.title { (post.frontmatter.title) }
|
|
||||||
@if let Some(ref date) = post.frontmatter.date {
|
|
||||||
time.date { (date) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if let Some(ref desc) = post.frontmatter.description {
|
|
||||||
p.description { (desc) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
page_path,
|
|
||||||
config,
|
|
||||||
nav,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the projects page with cards.
|
|
||||||
pub fn render_projects_index(
|
|
||||||
title: &str,
|
|
||||||
projects: &[Content],
|
|
||||||
page_path: &str,
|
|
||||||
config: &SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Markup {
|
|
||||||
base_layout(
|
|
||||||
title,
|
|
||||||
html! {
|
|
||||||
h1 { (title) }
|
|
||||||
ul.project-cards {
|
|
||||||
@for project in projects {
|
|
||||||
li.card {
|
|
||||||
@if let Some(ref link) = project.frontmatter.link_to {
|
|
||||||
a href=(link) target="_blank" rel="noopener" {
|
|
||||||
h2 { (project.frontmatter.title) }
|
|
||||||
@if let Some(ref desc) = project.frontmatter.description {
|
|
||||||
p { (desc) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
h2 { (project.frontmatter.title) }
|
|
||||||
@if let Some(ref desc) = project.frontmatter.description {
|
|
||||||
p { (desc) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
page_path,
|
|
||||||
config,
|
|
||||||
nav,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Base HTML layout wrapper.
|
|
||||||
fn base_layout(
|
|
||||||
title: &str,
|
|
||||||
content: Markup,
|
|
||||||
page_path: &str,
|
|
||||||
config: &SiteConfig,
|
|
||||||
nav: &[NavItem],
|
|
||||||
) -> Markup {
|
|
||||||
let depth = path_depth(page_path);
|
|
||||||
let prefix = relative_prefix(depth);
|
|
||||||
let canonical_url = format!("{}{}", config.base_url.trim_end_matches('/'), page_path);
|
|
||||||
|
|
||||||
html! {
|
|
||||||
(DOCTYPE)
|
|
||||||
html lang="en" {
|
|
||||||
head {
|
|
||||||
meta charset="utf-8";
|
|
||||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
|
||||||
title { (title) " | " (config.title) }
|
|
||||||
link rel="canonical" href=(canonical_url);
|
|
||||||
link rel="alternate" type="application/atom+xml" title="Atom Feed" href=(format!("{}/feed.xml", config.base_url.trim_end_matches('/')));
|
|
||||||
link rel="stylesheet" href=(format!("{}/style.css", prefix));
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
nav {
|
|
||||||
a href=(format!("{}/index.html", prefix)) { (config.title) }
|
|
||||||
@for item in nav {
|
|
||||||
a href=(format!("{}{}", prefix, item.path)) { (item.label) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
(content)
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
p { "© " (config.author) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_relative_prefix() {
|
|
||||||
assert_eq!(relative_prefix(0), ".");
|
|
||||||
assert_eq!(relative_prefix(1), "..");
|
|
||||||
assert_eq!(relative_prefix(2), "../..");
|
|
||||||
assert_eq!(relative_prefix(3), "../../..");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ title }} | {{ config.title }}</title>
|
<title>{{ title }} | {{ config.title }}</title>
|
||||||
<link rel="canonical" href="{{ config.base_url | trim_end_matches(pat='/') }}{{ page_path }}">
|
<link rel="canonical" href="{{ base_url }}{{ page_path }}">
|
||||||
<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{{ config.base_url | trim_end_matches(pat='/') }}/feed.xml">
|
<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{{ base_url }}/feed.xml">
|
||||||
<link rel="stylesheet" href="{{ prefix }}/style.css">
|
<link rel="stylesheet" href="{{ prefix }}/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="{{ prefix }}/index.html">{{ config.title }}</a>
|
<a href="{{ prefix }}/index.html">{{ config.title }}</a>
|
||||||
@@ -22,4 +24,5 @@
|
|||||||
<p>© {{ config.author }}</p>
|
<p>© {{ config.author }}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user