use std::collections::HashMap;
use crate::config::SiteConfig;
use crate::graph::PageStore;
use crate::parser::{PageId, ParsedPage};
use crate::render::toc::{self, TocEntry};
use minijinja::Value;
pub type PeerIndex = HashMap<String, Vec<PageId>>;
pub fn build_peer_index(store: &PageStore, config: &SiteConfig) -> PeerIndex {
let mut index: HashMap<String, Vec<PageId>> = HashMap::new();
for (page_id, page) in &store.pages {
if !PageStore::is_page_public(page, &config.content) {
continue;
}
let base = page.meta.title.rsplit('/').next()
.unwrap_or(&page.meta.title).to_lowercase();
index.entry(base).or_default().push(page_id.clone());
}
index
}
pub fn resolve_nav_menu(config: &SiteConfig, store: &PageStore) -> Vec<Value> {
if let Some(ref tag) = config.nav.menu_tag {
resolve_nav_menu_from_tag(tag, store)
} else {
resolve_nav_menu_from_config(config, store)
}
}
fn resolve_nav_menu_from_tag(tag: &str, store: &PageStore) -> Vec<Value> {
let tag_lower = tag.to_lowercase();
let mut menu_pages: Vec<&crate::parser::ParsedPage> = store
.pages
.values()
.filter(|page| page.meta.tags.iter().any(|t| t.to_lowercase() == tag_lower))
.collect();
menu_pages.sort_by(|a, b| {
let ord_a = a.meta.menu_order.unwrap_or(i32::MAX);
let ord_b = b.meta.menu_order.unwrap_or(i32::MAX);
ord_a
.cmp(&ord_b)
.then_with(|| a.meta.title.cmp(&b.meta.title))
});
menu_pages
.iter()
.map(|page| {
let url = format!("/{}", page.id);
let icon = page.meta.icon.clone();
let label = title_case(&page.meta.title);
minijinja::context! {
label => label,
url => url,
external => false,
active => false,
icon => icon,
}
})
.collect()
}
fn title_case(s: &str) -> String {
s.split_whitespace()
.map(|word| {
word.split('-')
.map(|segment| {
let mut chars = segment.chars();
match chars.next() {
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join("•")
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn generate_excerpt(md: &str, max_chars: usize) -> String {
let mut lines: Vec<&str> = Vec::new();
let mut in_code_fence = false;
for line in md.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_fence = !in_code_fence;
continue;
}
if in_code_fence {
continue;
}
if trimmed.is_empty() {
continue;
}
if trimmed == "---" {
continue;
}
let text = if trimmed.starts_with('#') {
trimmed.trim_start_matches('#').trim()
} else {
trimmed
};
let text = text
.strip_prefix("- ")
.or_else(|| text.strip_prefix("* "))
.unwrap_or(text);
if text.is_empty() {
continue;
}
lines.push(text);
}
let joined = lines.join(" ");
let mut result = String::with_capacity(joined.len());
let chars: Vec<char> = joined.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if i + 1 < len && chars[i] == '[' && chars[i + 1] == '[' {
i += 2;
while i + 1 < len && !(chars[i] == ']' && chars[i + 1] == ']') {
result.push(chars[i]);
i += 1;
}
if i + 1 < len {
i += 2; }
} else {
result.push(chars[i]);
i += 1;
}
}
let mut clean = String::with_capacity(result.len());
let chars: Vec<char> = result.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if i + 1 < len && chars[i] == '{' && chars[i + 1] == '{' {
i += 2;
while i + 1 < len && !(chars[i] == '}' && chars[i + 1] == '}') {
i += 1;
}
if i + 1 < len {
i += 2;
}
} else {
clean.push(chars[i]);
i += 1;
}
}
let collapsed: String = clean.split_whitespace().collect::<Vec<_>>().join(" ");
let char_count = collapsed.chars().count();
if char_count <= max_chars {
return collapsed;
}
let truncated: String = collapsed.chars().take(max_chars).collect();
if let Some(last_space) = truncated.rfind(' ') {
format!("{}…", &truncated[..last_space])
} else {
format!("{}…", truncated)
}
}
fn resolve_nav_menu_from_config(config: &SiteConfig, store: &PageStore) -> Vec<Value> {
config
.nav
.menu
.iter()
.map(|item| {
let slug = item
.page
.as_ref()
.map(|p| crate::parser::slugify_page_name(p));
let url = if let Some(ref s) = slug {
format!("/{}", s)
} else if let Some(ref url) = item.url {
url.clone()
} else {
"#".to_string()
};
let icon = slug
.as_ref()
.and_then(|s| store.pages.get(s))
.and_then(|p| p.meta.icon.clone())
.or_else(|| item.icon.clone());
minijinja::context! {
label => item.label.clone(),
url => url,
external => item.external,
active => false,
icon => icon,
}
})
.collect()
}
pub fn build_page_context(
page: &ParsedPage,
html_body: &str,
toc_entries: &[TocEntry],
store: &PageStore,
config: &SiteConfig,
peer_index: &PeerIndex,
) -> Value {
let backlinks = store.get_backlinks(&page.id);
let backlink_data: Vec<Value> = backlinks
.iter()
.map(|bl| {
minijinja::context! {
title => bl.title.clone(),
url => bl.url.clone(),
}
})
.collect();
let word_count = page.content_md.split_whitespace().count();
let reading_time = (word_count as f64 / 200.0).ceil() as usize;
let children: Vec<Value> = {
let page_name_lower = page.meta.title.to_lowercase();
let mut items: Vec<Value> = store
.get_namespace_children(&page_name_lower)
.iter()
.map(|child| {
minijinja::context! {
title => child.meta.title.rsplit('/').next().unwrap_or(&child.meta.title).to_string(),
url => format!("/{}", child.id),
}
})
.collect();
let prefix = format!("{}/", page_name_lower);
let mut seen_subns: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut folder_slugs: std::collections::HashSet<String> = std::collections::HashSet::new();
for ns_key in store.namespace_tree.keys() {
if let Some(rest) = ns_key.strip_prefix(&prefix) {
let sub = rest.split('/').next().unwrap_or(rest);
if seen_subns.insert(sub.to_string()) {
let sub_page_slug = crate::parser::slugify_page_name(&format!("{}/{}", page_name_lower, sub));
folder_slugs.insert(sub_page_slug.clone());
let url = format!("/{}", sub_page_slug);
items.push(minijinja::context! {
title => format!("{}/", sub),
url => url,
});
}
}
}
items.retain(|item| {
let url: String = item.get_attr("url").ok().and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default();
let title: String = item.get_attr("title").ok().and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default();
if title.ends_with('/') {
return true; }
let slug = url.trim_start_matches('/');
!folder_slugs.contains(slug)
});
items.sort_by(|a, b| {
let a_title: String = a.get_attr("title").ok().and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default();
let b_title: String = b.get_attr("title").ok().and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default();
let a_is_dir = a_title.ends_with('/');
let b_is_dir = b_title.ends_with('/');
match (a_is_dir, b_is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a_title.cmp(&b_title),
}
});
if let Some(ref root_name) = config.site.root_page {
let root_id = crate::parser::slugify_page_name(root_name);
if page.id == root_id {
let existing_urls: std::collections::HashSet<String> = items
.iter()
.filter_map(|item| {
item.get_attr("url").ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
})
.collect();
let nav_tag = config.nav.menu_tag.as_deref().unwrap_or("nav");
let mut top_level: Vec<(String, String)> = store
.pages
.iter()
.filter(|(id, p)| {
id.as_str() != page.id.as_str()
&& p.meta.tags.iter().any(|t| t.as_str() == nav_tag)
&& crate::graph::PageStore::is_page_public(p, &config.content)
})
.map(|(id, p)| (id.clone(), p.meta.title.clone()))
.collect();
top_level.sort_by(|(a, _), (b, _)| a.cmp(b));
for (id, title) in top_level {
let url = format!("/{}", id);
if !existing_urls.contains(&url) {
items.push(minijinja::context! {
title => format!("{}/", title),
url => url,
});
}
}
}
}
items
};
let nav_menu = resolve_nav_menu(config, store);
let toc_html = if toc_entries.len() >= 2 {
toc::render_toc_html(toc_entries, None)
} else {
String::new()
};
let namespace_parts: Vec<Value> = if let Some(ref ns) = page.namespace {
let segments: Vec<&str> = ns.split('/').collect();
let mut parts = Vec::new();
for (i, seg) in segments.iter().enumerate() {
let full_path = segments[..=i].join("/");
let slug = crate::parser::slugify_page_name(&full_path);
parts.push(minijinja::context! {
name => seg.to_string(),
url => format!("/{}", slug),
});
}
parts
} else {
vec![]
};
let description = page
.meta
.properties
.get("description")
.filter(|d| !d.is_empty())
.cloned()
.unwrap_or_else(|| {
let excerpt = generate_excerpt(&page.content_md, 160);
if excerpt.is_empty() {
page.meta.title.clone()
} else {
excerpt
}
});
let canonical_url = format!("{}/{}", config.site.base_url, page.id);
let base_name = page.meta.title.rsplit('/').next()
.unwrap_or(&page.meta.title).to_lowercase();
let mut dimensional_peers: Vec<Value> = peer_index
.get(&base_name)
.map(|ids| {
ids.iter()
.filter(|id| **id != page.id)
.filter_map(|id| store.pages.get(id))
.map(|peer| {
let excerpt = generate_excerpt(&peer.content_md, 300);
let depth = peer.meta.title.matches('/').count();
minijinja::context! {
title => peer.meta.title.clone(),
path => format!("/{}", peer.id),
icon => peer.meta.icon.clone(),
namespace => peer.namespace.clone(),
html_content => excerpt,
depth => depth,
}
})
.collect()
})
.unwrap_or_default();
let current_depth = page.meta.title.matches('/').count();
if current_depth == 0 {
dimensional_peers.sort_by(|a, b| {
let ad: i64 = a.get_attr("depth").ok().and_then(|v| i64::try_from(v).ok()).unwrap_or(0);
let bd: i64 = b.get_attr("depth").ok().and_then(|v| i64::try_from(v).ok()).unwrap_or(0);
bd.cmp(&ad)
});
} else {
dimensional_peers.sort_by(|a, b| {
let ad: i64 = a.get_attr("depth").ok().and_then(|v| i64::try_from(v).ok()).unwrap_or(0);
let bd: i64 = b.get_attr("depth").ok().and_then(|v| i64::try_from(v).ok()).unwrap_or(0);
ad.cmp(&bd)
});
}
let favicon = page
.meta
.icon
.clone()
.or_else(|| {
if let Some(ref ns) = page.namespace {
let segments: Vec<&str> = ns.split('/').collect();
for i in (0..segments.len()).rev() {
let parent_path = segments[..=i].join("/");
let parent_slug = crate::parser::slugify_page_name(&parent_path);
if let Some(parent) = store.pages.get(&parent_slug) {
if parent.meta.icon.is_some() {
return parent.meta.icon.clone();
}
}
}
}
None
})
.or_else(|| config.site.favicon.clone());
minijinja::context! {
site => config.site,
style => config.style,
nav_menu => nav_menu,
graph => config.graph,
analytics => config.analytics,
search => config.search,
favicon => favicon,
description => description,
canonical_url => canonical_url,
page => minijinja::context! {
title => page.meta.title.clone(),
display_name => {
let base = page.meta.title.rsplit('/').next().unwrap_or(&page.meta.title);
match page.kind {
crate::parser::PageKind::Page | crate::parser::PageKind::Journal => format!("{}.md", base),
crate::parser::PageKind::File => base.to_string(),
}
},
id => page.id.clone(),
html_content => html_body,
meta => page.meta.properties.clone(),
tags => page.meta.tags.clone(),
aliases => page.meta.aliases.clone(),
url => format!("/{}", page.id),
namespace => page.namespace.clone(),
namespace_parts => namespace_parts,
children => children,
word_count => word_count,
reading_time_minutes => reading_time,
date => page.meta.date.map(|d| d.format("%Y-%m-%d").to_string()),
icon => page.meta.icon.clone(),
kind => format!("{:?}", page.kind),
toc => toc_html,
focus => store.focus.get(&page.id).copied().unwrap_or(0.0),
is_private => {
let sg_private = page.subgraph.as_ref()
.map(|s| store.subgraph_private.contains(s))
.unwrap_or(false);
sg_private || page.meta.public == Some(false)
},
},
backlinks => backlink_data,
dimensional_peers => dimensional_peers,
}
}