pub mod context;
mod templates;
pub mod toc;
mod transform;
use crate::config::SiteConfig;
use crate::graph::PageStore;
use crate::lunar;
use crate::parser::PageId;
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct RenderedPage {
pub page_id: PageId,
pub html: String,
pub url_path: String,
}
pub fn render_all(store: &PageStore, config: &SiteConfig) -> Result<Vec<RenderedPage>> {
render_cached(store, config, &mut HashMap::new(), None)
}
/// Render pages with optional caching. When `dirty_ids` is Some, only pages in
/// the dirty set are re-rendered; clean pages are served from `cache`.
/// When `dirty_ids` is None, all pages are rendered (full rebuild).
/// The cache is updated with newly rendered pages.
pub fn render_cached(
store: &PageStore,
config: &SiteConfig,
cache: &mut HashMap<PageId, RenderedPage>,
dirty_ids: Option<&HashSet<PageId>>,
) -> Result<Vec<RenderedPage>> {
let env = templates::setup_environment(config.build.template_dir.as_deref(), config)?;
let mut rendered = Vec::new();
// Reserved URL slugs β synthetic pages take priority over regular pages
let reserved_slugs: std::collections::HashSet<&str> =
["tags", "blog", "graph", "files"]
.into_iter()
.collect();
for (page_id, page) in &store.pages {
// Skip regular pages that conflict with synthetic page URLs
if reserved_slugs.contains(page_id.as_str()) {
continue;
}
// Filter: public_only mode
if !PageStore::is_page_public(page, &config.content) {
continue;
}
// Check cache: skip rendering if page is clean
if let Some(dirty) = dirty_ids {
if !dirty.contains(page_id) {
if let Some(cached) = cache.get(page_id) {
rendered.push(cached.clone());
continue;
}
}
}
// Transform markdown to HTML with wikilink resolution
let render_result = transform::render_markdown(&page.content_md, store);
// Build template context
let ctx = context::build_page_context(
page,
&render_result.html,
&render_result.toc,
store,
config,
);
// Render through template
let template_name = match page.kind {
crate::parser::PageKind::Journal => "journal.html",
crate::parser::PageKind::Page | crate::parser::PageKind::File => "page.html",
};
let tmpl = env.get_template(template_name)?;
let html = tmpl.render(&ctx)?;
let url_path = if config.urls.style == "pretty" {
format!("/{}/index.html", page_id)
} else {
format!("/{}.html", page_id)
};
let rp = RenderedPage {
page_id: page_id.clone(),
html,
url_path,
};
cache.insert(page_id.clone(), rp.clone());
rendered.push(rp);
}
// Synthetic pages (index, tags, blog, files) only need re-rendering on
// structural changes (pages added/removed, tags changed). The caller signals
// this by including "__structural__" in the dirty set.
let any_dirty = dirty_ids
.map(|d| d.contains("__structural__"))
.unwrap_or(true);
// Helper: push a synthetic page, using cache when nothing is dirty
macro_rules! push_synthetic {
($id:expr, $html:expr, $url:expr) => {{
let id = $id.to_string();
if any_dirty {
let rp = RenderedPage {
page_id: id.clone(),
html: $html,
url_path: $url.to_string(),
};
cache.insert(id, rp.clone());
rendered.push(rp);
} else if let Some(cached) = cache.get(&id) {
rendered.push(cached.clone());
}
}};
}
// Render index page
if any_dirty {
let index_html = render_index(store, config, &env)?;
push_synthetic!("__index__", index_html, "/index.html");
} else if let Some(cached) = cache.get("__index__") {
rendered.push(cached.clone());
}
// Render tag pages (only for public pages)
let public_tags = store.public_tag_index(&config.content);
for (tag, page_ids) in &public_tags {
let tag_slug = slug::slugify(tag);
let id = format!("__tag__{}", tag_slug);
if any_dirty {
let tag_html = render_tag_page(tag, page_ids, store, config, &env)?;
let rp = RenderedPage {
page_id: id.clone(),
html: tag_html,
url_path: format!("/tags/{}/index.html", tag_slug),
};
cache.insert(id, rp.clone());
rendered.push(rp);
} else if let Some(cached) = cache.get(&id) {
rendered.push(cached.clone());
}
}
// Render tags index page
if any_dirty {
let tags_html = render_tags_index(store, config, &env)?;
push_synthetic!("__tags_index__", tags_html, "/tags/index.html");
} else if let Some(cached) = cache.get("__tags_index__") {
rendered.push(cached.clone());
}
// Render blog page (date-sorted page listing)
if any_dirty {
let blog_html = render_blog(store, config, &env)?;
push_synthetic!("__blog__", blog_html, "/blog/index.html");
} else if let Some(cached) = cache.get("__blog__") {
rendered.push(cached.clone());
}
// Render graph visualization page
if config.graph.enabled {
if any_dirty {
let graph_html = render_graph_page(store, config, &env)?;
push_synthetic!("__graph__", graph_html, "/graph/index.html");
} else if let Some(cached) = cache.get("__graph__") {
rendered.push(cached.clone());
}
}
// Render unified files page (all pages with size column)
if any_dirty {
let files_html = render_files_page(store, config, &env)?;
push_synthetic!("__files__", files_html, "/files/index.html");
} else if let Some(cached) = cache.get("__files__") {
rendered.push(cached.clone());
}
// Generate redirect pages for aliases
for (alias_slug, canonical_id) in &store.alias_map {
// Don't generate redirect if alias slug matches an existing page or reserved slug
if store.pages.contains_key(alias_slug) || reserved_slugs.contains(alias_slug.as_str()) {
continue;
}
let alias_id = format!("__alias__{}", alias_slug);
if !any_dirty {
if let Some(cached) = cache.get(&alias_id) {
rendered.push(cached.clone());
continue;
}
}
let redirect_html = format!(
r#"<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=/{canonical_id}"><link rel="canonical" href="/{canonical_id}"><title>Redirectingβ¦</title></head><body>Redirecting to <a href="/{canonical_id}">/{canonical_id}</a></body></html>"#
);
let rp = RenderedPage {
page_id: alias_id.clone(),
html: redirect_html,
url_path: format!("/{}/index.html", alias_slug),
};
cache.insert(alias_id, rp.clone());
rendered.push(rp);
}
Ok(rendered)
}
fn render_index(
store: &PageStore,
config: &SiteConfig,
env: &minijinja::Environment,
) -> Result<String> {
// If there's a root page configured, render it as index
if let Some(ref root_page_name) = config.site.root_page {
let root_id = crate::parser::slugify_page_name(root_page_name);
if let Some(page) = store.pages.get(&root_id) {
if PageStore::is_page_public(page, &config.content) {
let render_result = transform::render_markdown(&page.content_md, store);
let ctx = context::build_page_context(
page,
&render_result.html,
&render_result.toc,
store,
config,
);
let tmpl = env.get_template("page.html")?;
return Ok(tmpl.render(&ctx)?);
}
}
}
// Auto-generated index β only show public pages
let public_count = store.public_pages(&config.content).len();
let recent = store.recent_pages(20, &config.content);
let recent_data: Vec<_> = recent
.iter()
.map(|p| {
minijinja::context! {
title => p.meta.title.clone(),
url => format!("/{}", p.id),
date => p.meta.date.map(|d| d.format("%Y-%m-%d").to_string()),
tags => p.meta.tags.clone(),
icon => p.meta.icon.clone(),
}
})
.collect();
let tags = store.all_tags(&config.content);
let tag_data: Vec<_> = tags
.iter()
.map(|(name, count)| {
minijinja::context! {
name => *name,
count => *count,
url => format!("/tags/{}", slug::slugify(name)),
}
})
.collect();
let ctx = minijinja::context! {
site => config.site,
style => config.style,
nav_menu => context::resolve_nav_menu(config, store),
search => config.search,
analytics => config.analytics,
graph => config.graph,
favicon => config.site.favicon,
description => config.site.description,
canonical_url => config.site.base_url,
recent_pages => recent_data,
tags => tag_data,
page_count => public_count,
};
let tmpl = env.get_template("index.html")?;
Ok(tmpl.render(&ctx)?)
}
fn render_tags_index(
store: &PageStore,
config: &SiteConfig,
env: &minijinja::Environment,
) -> Result<String> {
let tags = store.all_tags(&config.content);
let max_count = tags.iter().map(|(_, c)| *c).max().unwrap_or(1) as f64;
let tag_data: Vec<_> = tags
.iter()
.map(|(name, count)| {
// Scale font size from 0.8 to 2.0 based on count
let ratio = (*count as f64) / max_count;
let size = 0.8 + ratio * 1.2;
minijinja::context! {
name => *name,
count => *count,
url => format!("/tags/{}", slug::slugify(name)),
size => format!("{:.1}", size),
}
})
.collect();
let public_count = store.public_pages(&config.content).len();
let ctx = minijinja::context! {
site => config.site,
style => config.style,
nav_menu => context::resolve_nav_menu(config, store),
search => config.search,
analytics => config.analytics,
graph => config.graph,
favicon => config.site.favicon,
description => format!("All tags β {}", config.site.title),
canonical_url => format!("{}/tags", config.site.base_url),
tags => tag_data,
page_count => public_count,
};
let tmpl = env.get_template("tags-index.html")?;
Ok(tmpl.render(&ctx)?)
}
fn render_graph_page(
store: &PageStore,
config: &SiteConfig,
env: &minijinja::Environment,
) -> Result<String> {
let public_count = store.public_pages(&config.content).len();
let ctx = minijinja::context! {
site => config.site,
style => config.style,
nav_menu => context::resolve_nav_menu(config, store),
search => config.search,
analytics => config.analytics,
graph => config.graph,
favicon => config.site.favicon,
description => format!("Knowledge graph β {}", config.site.title),
canonical_url => format!("{}/graph", config.site.base_url),
page_count => public_count,
};
let tmpl = env.get_template("graph.html")?;
Ok(tmpl.render(&ctx)?)
}
/// Truncate markdown to first N non-empty content lines (bullet items).
/// Returns (truncated_md, was_truncated).
fn truncate_markdown(md: &str, max_lines: usize) -> (String, bool) {
let content_lines: Vec<&str> = md.lines().filter(|l| !l.trim().is_empty()).collect();
if content_lines.len() <= max_lines {
(md.to_string(), false)
} else {
let truncated = content_lines[..max_lines].join("\n");
(truncated, true)
}
}
fn render_blog(
store: &PageStore,
config: &SiteConfig,
env: &minijinja::Environment,
) -> Result<String> {
use crate::parser::PageKind;
// Blog = journal entries, sorted by date descending
let mut journals: Vec<_> = store
.pages
.values()
.filter(|p| {
p.kind == PageKind::Journal
&& PageStore::is_page_public(p, &config.content)
&& p.meta.date.is_some()
})
.collect();
journals.sort_by(|a, b| b.meta.date.unwrap().cmp(&a.meta.date.unwrap()));
let page_data: Vec<_> = journals
.iter()
.map(|p| {
// Truncate to 3 lines max, then render as full HTML with wikilinks
let (truncated_md, was_truncated) = truncate_markdown(&p.content_md, 3);
let render_result = transform::render_markdown(&truncated_md, store);
minijinja::context! {
title => p.meta.date.map(|d| d.format("%B %d, %Y").to_string()).unwrap_or_else(|| p.meta.title.clone()),
url => format!("/{}", p.id),
date => p.meta.date.map(|d| d.format("%Y-%m-%d").to_string()),
tags => p.meta.tags.clone(),
icon => p.meta.icon.clone(),
content_html => render_result.html,
truncated => was_truncated,
}
})
.collect();
let public_count = store.public_pages(&config.content).len();
let ctx = minijinja::context! {
site => config.site,
style => config.style,
nav_menu => context::resolve_nav_menu(config, store),
search => config.search,
analytics => config.analytics,
graph => config.graph,
favicon => config.site.favicon,
description => format!("Blog β {}", config.site.title),
canonical_url => format!("{}/blog", config.site.base_url),
posts => page_data,
page_count => public_count,
};
let tmpl = env.get_template("blog.html")?;
Ok(tmpl.render(&ctx)?)
}
fn render_tag_page(
tag: &str,
page_ids: &[PageId],
store: &PageStore,
config: &SiteConfig,
env: &minijinja::Environment,
) -> Result<String> {
let pages: Vec<_> = page_ids
.iter()
.filter_map(|id| {
store.pages.get(id).map(|p| {
minijinja::context! {
title => p.meta.title.clone(),
url => format!("/{}", p.id),
date => p.meta.date.map(|d| d.format("%Y-%m-%d").to_string()),
}
})
})
.collect();
let ctx = minijinja::context! {
site => config.site,
style => config.style,
nav_menu => context::resolve_nav_menu(config, store),
search => config.search,
analytics => config.analytics,
graph => config.graph,
favicon => config.site.favicon,
description => format!("Pages tagged '{}' β {}", tag, config.site.title),
canonical_url => format!("{}/tags/{}", config.site.base_url, slug::slugify(tag)),
tag_name => tag,
pages => pages,
};
let tmpl = env.get_template("tag.html")?;
Ok(tmpl.render(&ctx)?)
}
/// Format byte size into human-readable string.
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
/// Extract git dates for all files under a directory.
/// Returns a map: absolute_path -> (created_iso, modified_iso).
/// Runs a single `git log` command for efficiency.
fn git_dates(input_dir: &Path) -> HashMap<String, (String, String)> {
// Find the git repo root
let repo_root = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(input_dir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()));
let repo_root = match repo_root {
Some(r) => r,
None => return HashMap::new(),
};
let output = std::process::Command::new("git")
.args(["log", "--format=format:%aI", "--name-only", "--diff-filter=ACMR"])
.current_dir(&repo_root)
.output();
let output = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => return HashMap::new(),
};
// Parse: date line followed by filename lines, separated by blank lines.
// Git log is newest-first, so first encounter = modified, last = created.
let mut result: HashMap<String, (String, String)> = HashMap::new();
let mut current_date = String::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Date lines start with digit (ISO format: 2026-03-02T...)
if line.starts_with(|c: char| c.is_ascii_digit()) && line.contains('T') {
current_date = line.to_string();
} else if !current_date.is_empty() {
// Convert git-relative path to absolute
let abs_path = repo_root.join(line);
let key = abs_path.to_string_lossy().to_string();
let entry = result
.entry(key)
.or_insert_with(|| (current_date.clone(), current_date.clone()));
// Newest-first: first insert sets modified, keep overwriting created
entry.0 = current_date.clone();
}
}
result
}
/// Compute percentile ranks (0.0β1.0) for a slice of values.
fn compute_percentiles(values: &[f64]) -> Vec<f64> {
let n = values.len();
if n <= 1 {
return vec![0.0; n];
}
let mut indexed: Vec<(usize, f64)> = values.iter().copied().enumerate().collect();
indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut result = vec![0.0; n];
let divisor = (n - 1).max(1) as f64;
for (rank, (orig_idx, _)) in indexed.iter().enumerate() {
result[*orig_idx] = rank as f64 / divisor;
}
result
}
/// Convert LMT "DD.MM.YY" to sortable "YY.MM.DD" with zero-padding.
fn lmt_sort_key(lmt: &str) -> String {
let parts: Vec<&str> = lmt.split('.').collect();
if parts.len() == 3 {
format!("{:0>3}.{:0>2}.{:0>2}", parts[2], parts[1], parts[0])
} else {
String::new()
}
}
/// Format a value as percentage of total: (val / sum) * 100, with 2 decimal places.
fn format_pct_of_sum(val: f64, sum: f64) -> String {
if sum <= 0.0 || val <= 0.0 {
"0".to_string()
} else {
format!("{:.2}", val / sum * 100.0)
}
}
/// Render the unified /files page β all public pages sorted by focus (Ο) with size column.
fn render_files_page(
store: &PageStore,
config: &SiteConfig,
env: &minijinja::Environment,
) -> Result<String> {
// Extract git dates for all files
let dates = git_dates(&config.build.input_dir);
let mut pages: Vec<_> = store
.public_pages(&config.content)
.into_iter()
.map(|p| {
let links_in = store.backlinks.get(&p.id).map(|b| b.len()).unwrap_or(0);
let links_out = store.forward_links.get(&p.id).map(|f| f.len()).unwrap_or(0);
let focus = store.focus.get(&p.id).copied().unwrap_or(0.0);
let gravity = store.gravity.get(&p.id).copied().unwrap_or(0.0);
let size = if p.source_path.as_os_str().is_empty() {
0u64
} else {
std::fs::metadata(&p.source_path)
.map(|m| m.len())
.unwrap_or(0)
};
let density = if size > 0 { focus / size as f64 } else { 0.0 };
(p, links_in, links_out, focus, size, density, gravity)
})
.collect();
// Sort by focus descending
pages.sort_by(|a, b| b.3.partial_cmp(&a.3).unwrap_or(std::cmp::Ordering::Equal));
// Compute percentiles for coloring
let size_vals: Vec<f64> = pages.iter().map(|r| r.4 as f64).collect();
let focus_vals: Vec<f64> = pages.iter().map(|r| r.3).collect();
let in_vals: Vec<f64> = pages.iter().map(|r| r.1 as f64).collect();
let out_vals: Vec<f64> = pages.iter().map(|r| r.2 as f64).collect();
let den_vals: Vec<f64> = pages.iter().map(|r| r.5).collect();
let grav_vals: Vec<f64> = pages.iter().map(|r| r.6).collect();
let size_pcts = compute_percentiles(&size_vals);
let focus_pcts = compute_percentiles(&focus_vals);
let in_pcts = compute_percentiles(&in_vals);
let out_pcts = compute_percentiles(&out_vals);
let den_pcts = compute_percentiles(&den_vals);
let grav_pcts = compute_percentiles(&grav_vals);
// Sum values for normalizing density and gravity to distributions (Ξ£ = 1)
let sum_den: f64 = den_vals.iter().sum();
let sum_grav: f64 = grav_vals.iter().sum();
let files_data: Vec<_> = pages
.iter()
.enumerate()
.map(|(i, (p, links_in, links_out, focus, size, density, gravity))| {
let focus_display = format!("{:.2}", focus * 100.0);
let size_display = format_size(*size);
let file_title = match p.kind {
crate::parser::PageKind::Page | crate::parser::PageKind::Journal => format!("{}.md", p.meta.title),
crate::parser::PageKind::File => p.meta.title.clone(),
};
// Look up git dates by absolute source path
let abs_key = p.source_path.to_string_lossy().to_string();
let (created_lmt, modified_lmt) = dates
.get(&abs_key)
.map(|(created, modified)| {
let c = lunar::iso_to_lmt(created).unwrap_or_default();
let m = lunar::iso_to_lmt(modified).unwrap_or_default();
(c, m)
})
.unwrap_or_default();
// HSL lightness: 95% (low) β 35% (high) β maps percentile to green intensity
let size_light = 95.0 - size_pcts[i] * 60.0;
let focus_light = 95.0 - focus_pcts[i] * 60.0;
let in_light = 95.0 - in_pcts[i] * 60.0;
let out_light = 95.0 - out_pcts[i] * 60.0;
let den_light = 95.0 - den_pcts[i] * 60.0;
let grav_light = 95.0 - grav_pcts[i] * 60.0;
minijinja::context! {
rank => i + 1,
title => file_title,
url => format!("/{}", p.id),
links_in => *links_in,
links_out => *links_out,
pagerank => focus_display,
size => size_display,
density => format_pct_of_sum(*density, sum_den),
gravity => format_pct_of_sum(*gravity, sum_grav),
tags => p.meta.tags.clone(),
icon => p.meta.icon.clone(),
created => created_lmt,
modified => modified_lmt,
size_sort => *size,
focus_sort => format!("{:.8}", focus),
in_sort => *links_in,
out_sort => *links_out,
density_sort => format!("{:.8}", density),
gravity_sort => format!("{:.10}", gravity),
created_sort => lmt_sort_key(&created_lmt),
modified_sort => lmt_sort_key(&modified_lmt),
size_light => format!("{:.0}", size_light),
focus_light => format!("{:.0}", focus_light),
in_light => format!("{:.0}", in_light),
out_light => format!("{:.0}", out_light),
den_light => format!("{:.0}", den_light),
grav_light => format!("{:.0}", grav_light),
}
})
.collect();
let total = files_data.len();
let ctx = minijinja::context! {
site => config.site,
style => config.style,
nav_menu => context::resolve_nav_menu(config, store),
search => config.search,
analytics => config.analytics,
graph => config.graph,
favicon => config.site.favicon,
description => format!("{} files in the knowledge graph", total),
canonical_url => format!("{}/files", config.site.base_url),
page_count => total,
files => files_data,
total_files => total,
};
let tmpl = env.get_template("files.html")?;
Ok(tmpl.render(&ctx)?)
}
render/src/render/mod.rs
Ο 0.0%