Menu
Public repository / master
AkurAI-Notes / crates/notes-api/src/handlers/ssr.rs
Latest change d2e70c7ce91e59fd601d2d47a0e4ead5450385c8 - feat: add create_notebook, create_tag, list_tags, list_notes MCP tools; fix add_tag/remove_tag stubs; add notebookId/archived to update_note by Claude Code
use crate::router_setup::{internal_error, NotesState};
use akurai_http::{Request, Response};
use akurai_json::Value;
use akurai_template::Engine;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
pub type RouteHandler = Box<dyn Fn(&Request) -> Response + Send + Sync>;
fn negotiate_locale(req: &Request, known: &[String]) -> String {
if let Some(cookie) = req.header("Cookie") {
for pair in cookie.split(';') {
let pair = pair.trim();
if let Some(val) = pair
.strip_prefix("akurai-lang=")
.or_else(|| pair.strip_prefix("akurai_lang="))
{
let lang = val.trim().trim_matches('"').to_string();
if known.iter().any(|l| l == &lang) {
return lang;
}
}
}
}
if let Some(accept) = req.header("Accept-Language") {
for part in accept.split(',') {
let lang = part
.split(';')
.next()
.unwrap_or("")
.trim()
.split('-')
.next()
.unwrap_or("")
.trim();
if !lang.is_empty() && known.iter().any(|l| l == lang) {
return lang.to_string();
}
}
}
"en".into()
}
fn build_context(backend: &Path, req: &Request) -> Value {
let mut pairs: Vec<(String, Value)> = Vec::new();
pairs.push(("version".into(), Value::Str("v0.0.1".into())));
let page_path = backend.join("page.json");
if page_path.exists() {
if let Ok(text) = fs::read_to_string(&page_path) {
if let Ok(Value::Object(p)) = akurai_json::parse(&text) {
for (k, v) in p {
pairs.push((k.clone(), v.clone()));
}
}
}
}
let locales_dir = backend.join("locales");
let mut known: Vec<String> = Vec::new();
let mut catalogs: Vec<(String, Vec<(String, Value)>)> = Vec::new();
if locales_dir.exists() {
if let Ok(entries) = fs::read_dir(&locales_dir) {
for entry in entries {
let path = match entry {
Ok(e) => e.path(),
Err(_) => continue,
};
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let locale = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if let Ok(text) = fs::read_to_string(&path) {
if let Ok(Value::Object(msgs)) = akurai_json::parse(&text) {
known.push(locale.clone());
catalogs.push((locale, msgs));
}
}
}
}
}
let locale = negotiate_locale(req, &known);
pairs.push(("__locale".into(), Value::Str(locale.clone())));
for (l, msgs) in &catalogs {
if *l == locale {
pairs.push(("__messages".into(), Value::Object(msgs.clone())));
break;
}
}
Value::Object(pairs)
}
fn build_engine(frontend: &Path) -> Result<Engine, String> {
let mut engine = Engine::new();
let entries = fs::read_dir(frontend).map_err(|e| format!("cannot read frontend: {e}"))?;
for entry in entries {
let path = match entry {
Ok(e) => e.path(),
Err(_) => continue,
};
if path.extension().and_then(|e| e.to_str()) != Some("html") {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let source = match fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
if let Err(e) = engine.register(&stem, &source) {
eprintln!("ssr: failed to register {stem}.html: {}", e.message);
}
}
Ok(engine)
}
pub fn render_template(
frontend: &Path,
backend: &Path,
template: &str,
layout: Option<&str>,
req: &Request,
) -> Response {
let engine = match build_engine(frontend) {
Ok(e) => e,
Err(msg) => return Response::new(500).with_text(&msg),
};
let context = build_context(backend, req);
let page = match engine
.render(template, &context)
.or_else(|_| engine.render("index", &context))
{
Ok(html) => html,
Err(e) => {
let fallback = match fs::read_to_string(frontend.join("index.html")) {
Ok(s) => s,
Err(_) => format!("<h1>500</h1><p>template error: {}</p>", e.message),
};
return Response::new(500)
.with_header("Content-Type", "text/html; charset=utf-8")
.with_body("text/html", fallback.into_bytes());
}
};
match layout {
Some(layout_name) => {
let mut ctx = context;
if let Value::Object(pairs) = &mut ctx {
pairs.push(("content".into(), Value::Str(page)));
}
match engine.render(layout_name, &ctx) {
Ok(html) => Response::ok()
.with_header("Content-Type", "text/html; charset=utf-8")
.with_body("text/html; charset=utf-8", html.into_bytes()),
Err(e) => {
let msg = format!(
"<h1>500</h1><p>layout error ({}): {}</p>",
layout_name, e.message
);
Response::new(500)
.with_header("Content-Type", "text/html; charset=utf-8")
.with_body("text/html", msg.into_bytes())
}
}
}
None => Response::ok()
.with_header("Content-Type", "text/html; charset=utf-8")
.with_body("text/html; charset=utf-8", page.into_bytes()),
}
}
pub fn ssr_page_route(
state: Arc<Mutex<NotesState>>,
template: &'static str,
layout: Option<&'static str>,
) -> RouteHandler {
Box::new(move |req: &Request| -> Response {
let s = match state.lock() {
Ok(s) => s,
Err(_) => return internal_error("lock poisoned"),
};
let frontend = &s.frontend_dir;
let Some(parent) = frontend.parent() else {
return Response::new(500).with_text("cannot find parent dir");
};
let backend = parent.join("backend");
render_template(frontend, &backend, template, layout, req)
})
}