//! Publiseringsmotor: Tera-templates med innebygde temaer. //! //! Fire temaer: avis, magasin, blogg, tidsskrift. //! Hvert tema har artikkelmal + forside-mal. //! CSS-variabler for theme_config-overstyring. //! //! Ref: docs/concepts/publisering.md § "Temaer" use axum::{ extract::{Path, State}, http::{header, StatusCode}, response::Response, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tera::{Context, Tera}; use uuid::Uuid; use crate::AppState; // ============================================================================= // Tema-konfigurasjon fra publishing-trait // ============================================================================= #[derive(Deserialize, Default, Debug)] pub struct PublishingConfig { pub slug: Option, pub theme: Option, #[serde(default)] pub theme_config: ThemeConfig, pub custom_domain: Option, pub index_mode: Option, pub featured_max: Option, pub stream_page_size: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] pub struct ThemeConfig { #[serde(default)] pub colors: ColorConfig, #[serde(default)] pub typography: TypographyConfig, #[serde(default)] pub layout: LayoutConfig, pub logo_hash: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] pub struct ColorConfig { pub primary: Option, pub accent: Option, pub background: Option, pub text: Option, pub muted: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] pub struct TypographyConfig { pub heading_font: Option, pub body_font: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] pub struct LayoutConfig { pub max_width: Option, } // ============================================================================= // Innebygde temaer — Tera-templates // ============================================================================= /// Tema-defaults for CSS-variabler per tema. struct ThemeDefaults { primary: &'static str, accent: &'static str, background: &'static str, text: &'static str, muted: &'static str, heading_font: &'static str, body_font: &'static str, max_width: &'static str, } fn theme_defaults(theme: &str) -> ThemeDefaults { match theme { "avis" => ThemeDefaults { primary: "#1a1a2e", accent: "#e94560", background: "#ffffff", text: "#1a1a2e", muted: "#6b7280", heading_font: "'Georgia', 'Times New Roman', serif", body_font: "'Charter', 'Georgia', serif", max_width: "1200px", }, "magasin" => ThemeDefaults { primary: "#2d3436", accent: "#0984e3", background: "#fafafa", text: "#2d3436", muted: "#636e72", heading_font: "'Playfair Display', 'Georgia', serif", body_font: "system-ui, -apple-system, sans-serif", max_width: "1100px", }, "blogg" => ThemeDefaults { primary: "#2c3e50", accent: "#3498db", background: "#ffffff", text: "#333333", muted: "#7f8c8d", heading_font: "system-ui, -apple-system, sans-serif", body_font: "system-ui, -apple-system, sans-serif", max_width: "720px", }, "tidsskrift" => ThemeDefaults { primary: "#1a1a1a", accent: "#8b0000", background: "#fffff8", text: "#1a1a1a", muted: "#555555", heading_font: "'Georgia', 'Times New Roman', serif", body_font: "'Georgia', 'Times New Roman', serif", max_width: "680px", }, // Fallback til blogg-defaults _ => theme_defaults("blogg"), } } /// Generer CSS-variabler fra theme_config med tema-defaults som fallback. fn build_css_variables(theme: &str, config: &ThemeConfig) -> String { let defaults = theme_defaults(theme); format!( r#":root {{ --color-primary: {primary}; --color-accent: {accent}; --color-background: {background}; --color-text: {text}; --color-muted: {muted}; --font-heading: {heading_font}; --font-body: {body_font}; --layout-max-width: {max_width}; }}"#, primary = config.colors.primary.as_deref().unwrap_or(defaults.primary), accent = config.colors.accent.as_deref().unwrap_or(defaults.accent), background = config.colors.background.as_deref().unwrap_or(defaults.background), text = config.colors.text.as_deref().unwrap_or(defaults.text), muted = config.colors.muted.as_deref().unwrap_or(defaults.muted), heading_font = config.typography.heading_font.as_deref().unwrap_or(defaults.heading_font), body_font = config.typography.body_font.as_deref().unwrap_or(defaults.body_font), max_width = config.layout.max_width.as_deref().unwrap_or(defaults.max_width), ) } // ============================================================================= // Tera engine med innebygde templates // ============================================================================= /// Bygg Tera-instans med alle innebygde temaer. pub fn build_tera() -> Tera { let mut tera = Tera::default(); // Base-template (felles for alle temaer) tera.add_raw_template("base.html", include_str!("templates/base.html")) .expect("Feil i base.html template"); // Avis tera.add_raw_template("avis/article.html", include_str!("templates/avis/article.html")) .expect("Feil i avis/article.html"); tera.add_raw_template("avis/index.html", include_str!("templates/avis/index.html")) .expect("Feil i avis/index.html"); // Magasin tera.add_raw_template("magasin/article.html", include_str!("templates/magasin/article.html")) .expect("Feil i magasin/article.html"); tera.add_raw_template("magasin/index.html", include_str!("templates/magasin/index.html")) .expect("Feil i magasin/index.html"); // Blogg tera.add_raw_template("blogg/article.html", include_str!("templates/blogg/article.html")) .expect("Feil i blogg/article.html"); tera.add_raw_template("blogg/index.html", include_str!("templates/blogg/index.html")) .expect("Feil i blogg/index.html"); // Tidsskrift tera.add_raw_template("tidsskrift/article.html", include_str!("templates/tidsskrift/article.html")) .expect("Feil i tidsskrift/article.html"); tera.add_raw_template("tidsskrift/index.html", include_str!("templates/tidsskrift/index.html")) .expect("Feil i tidsskrift/index.html"); tera } // ============================================================================= // Datamodeller for rendering // ============================================================================= #[derive(Serialize, Clone)] pub struct ArticleData { pub id: String, pub short_id: String, pub title: String, pub content: String, pub summary: Option, pub published_at: String, pub published_at_short: String, } #[derive(Serialize)] pub struct IndexData { pub title: String, pub description: Option, pub hero: Option, pub featured: Vec, pub stream: Vec, } // ============================================================================= // Render-funksjoner // ============================================================================= /// Render en artikkel med gitt tema. pub fn render_article( tera: &Tera, theme: &str, config: &ThemeConfig, article: &ArticleData, collection_title: &str, base_url: &str, ) -> Result { let css_vars = build_css_variables(theme, config); let template_name = format!("{theme}/article.html"); let mut ctx = Context::new(); ctx.insert("css_variables", &css_vars); ctx.insert("theme", theme); ctx.insert("article", article); ctx.insert("collection_title", collection_title); ctx.insert("base_url", base_url); ctx.insert("logo_hash", &config.logo_hash); tera.render(&template_name, &ctx) } /// Render forsiden med gitt tema. pub fn render_index( tera: &Tera, theme: &str, config: &ThemeConfig, index: &IndexData, base_url: &str, ) -> Result { let css_vars = build_css_variables(theme, config); let template_name = format!("{theme}/index.html"); let mut ctx = Context::new(); ctx.insert("css_variables", &css_vars); ctx.insert("theme", theme); ctx.insert("index", index); ctx.insert("base_url", base_url); ctx.insert("logo_hash", &config.logo_hash); tera.render(&template_name, &ctx) } // ============================================================================= // Database-spørringer // ============================================================================= struct CollectionRow { id: Uuid, title: Option, publishing_config: PublishingConfig, } /// Finn samling med publishing-trait basert på slug. async fn find_publishing_collection( db: &PgPool, slug: &str, ) -> Result, sqlx::Error> { let row: Option<(Uuid, Option, serde_json::Value)> = sqlx::query_as( r#" SELECT id, title, metadata FROM nodes WHERE node_kind = 'collection' AND metadata->'traits'->'publishing'->>'slug' = $1 LIMIT 1 "#, ) .bind(slug) .fetch_optional(db) .await?; let Some((id, title, metadata)) = row else { return Ok(None); }; let publishing_config: PublishingConfig = metadata .get("traits") .and_then(|t| t.get("publishing")) .cloned() .map(|v| serde_json::from_value(v).unwrap_or_default()) .unwrap_or_default(); Ok(Some(CollectionRow { id, title, publishing_config, })) } /// Hent artikkeldata for en enkelt node (belongs_to samlingen). async fn fetch_article( db: &PgPool, collection_id: Uuid, article_short_id: &str, ) -> Result)>, sqlx::Error> { // short_id er de første 8 tegnene av UUID let pattern = format!("{article_short_id}%"); let row: Option<(Uuid, Option, Option, DateTime, Option)> = sqlx::query_as( r#" SELECT n.id, n.title, n.content, n.created_at, e.metadata FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' AND n.id::text LIKE $2 LIMIT 1 "#, ) .bind(collection_id) .bind(&pattern) .fetch_optional(db) .await?; let Some((id, title, content, created_at, edge_meta)) = row else { return Ok(None); }; let publish_at = edge_meta .as_ref() .and_then(|m| m.get("publish_at")) .and_then(|v| v.as_str()) .and_then(|s| s.parse::>().ok()) .unwrap_or(created_at); let article = ArticleData { id: id.to_string(), short_id: id.to_string()[..8].to_string(), title: title.unwrap_or_else(|| "Uten tittel".to_string()), content: content.unwrap_or_default(), summary: None, published_at: publish_at.to_rfc3339(), published_at_short: publish_at.format("%e. %B %Y").to_string(), }; Ok(Some((article, edge_meta))) } /// Hent artikler for forsiden, sortert i slots. async fn fetch_index_articles( db: &PgPool, collection_id: Uuid, featured_max: i64, stream_page_size: i64, ) -> Result<(Option, Vec, Vec), sqlx::Error> { // Hent alle publiserte artikler med edge-metadata let rows: Vec<(Uuid, Option, Option, DateTime, Option)> = sqlx::query_as( r#" SELECT n.id, n.title, n.content, n.created_at, e.metadata FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' ORDER BY COALESCE( (e.metadata->>'publish_at')::timestamptz, n.created_at ) DESC "#, ) .bind(collection_id) .fetch_all(db) .await?; let mut hero: Option = None; let mut featured: Vec = Vec::new(); let mut stream: Vec = Vec::new(); for (id, title, content, created_at, edge_meta) in rows { let slot = edge_meta .as_ref() .and_then(|m| m.get("slot")) .and_then(|v| v.as_str()) .unwrap_or(""); let publish_at = edge_meta .as_ref() .and_then(|m| m.get("publish_at")) .and_then(|v| v.as_str()) .and_then(|s| s.parse::>().ok()) .unwrap_or(created_at); let summary = content .as_deref() .map(|c| truncate(c, 200)); let article = ArticleData { id: id.to_string(), short_id: id.to_string()[..8].to_string(), title: title.unwrap_or_else(|| "Uten tittel".to_string()), content: content.unwrap_or_default(), summary, published_at: publish_at.to_rfc3339(), published_at_short: publish_at.format("%e. %B %Y").to_string(), }; match slot { "hero" if hero.is_none() => hero = Some(article), "featured" if (featured.len() as i64) < featured_max => featured.push(article), _ => { if (stream.len() as i64) < stream_page_size { stream.push(article); } } } } Ok((hero, featured, stream)) } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } match s[..max].rfind(' ') { Some(pos) => format!("{}…", &s[..pos]), None => format!("{}…", &s[..max]), } } // ============================================================================= // HTTP-handlers // ============================================================================= /// GET /pub/{slug} — forside for en publikasjon. pub async fn serve_index( State(state): State, Path(slug): Path, ) -> Result { let collection = find_publishing_collection(&state.db, &slug) .await .map_err(|e| { tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg"); let config = &collection.publishing_config.theme_config; let featured_max = collection.publishing_config.featured_max.unwrap_or(4); let stream_page_size = collection.publishing_config.stream_page_size.unwrap_or(20); let (hero, featured, stream) = fetch_index_articles( &state.db, collection.id, featured_max, stream_page_size, ) .await .map_err(|e| { tracing::error!(slug = %slug, error = %e, "Feil ved henting av forsideartikler"); StatusCode::INTERNAL_SERVER_ERROR })?; let collection_title = collection.title.unwrap_or_else(|| slug.clone()); let base_url = collection .publishing_config .custom_domain .as_deref() .map(|d| format!("https://{d}")) .unwrap_or_else(|| format!("/pub/{slug}")); let index_data = IndexData { title: collection_title, description: None, hero, featured, stream, }; let tera = build_tera(); let html = render_index(&tera, theme, config, &index_data, &base_url).map_err(|e| { tracing::error!(slug = %slug, theme = %theme, error = %e, "Tera render-feil (index)"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Response::builder() .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .header(header::CACHE_CONTROL, "public, max-age=60") .body(html.into()) .unwrap()) } /// GET /pub/{slug}/{article_id} — enkeltartikkel. pub async fn serve_article( State(state): State, Path((slug, article_id)): Path<(String, String)>, ) -> Result { let collection = find_publishing_collection(&state.db, &slug) .await .map_err(|e| { tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg"); let config = &collection.publishing_config.theme_config; let (article, _edge_meta) = fetch_article(&state.db, collection.id, &article_id) .await .map_err(|e| { tracing::error!(slug = %slug, article = %article_id, error = %e, "Feil ved henting av artikkel"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let collection_title = collection.title.unwrap_or_else(|| slug.clone()); let base_url = collection .publishing_config .custom_domain .as_deref() .map(|d| format!("https://{d}")) .unwrap_or_else(|| format!("/pub/{slug}")); let tera = build_tera(); let html = render_article(&tera, theme, config, &article, &collection_title, &base_url) .map_err(|e| { tracing::error!(slug = %slug, article = %article_id, theme = %theme, error = %e, "Tera render-feil (artikkel)"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Response::builder() .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .header(header::CACHE_CONTROL, "public, max-age=300") .body(html.into()) .unwrap()) } /// GET /pub/{slug}/preview/{theme} — forhåndsvisning av tema med testdata. /// Nyttig for å se hvordan et tema ser ut uten reelle data. pub async fn preview_theme( Path((slug, theme)): Path<(String, String)>, ) -> Result { let valid_themes = ["avis", "magasin", "blogg", "tidsskrift"]; if !valid_themes.contains(&theme.as_str()) { return Err(StatusCode::NOT_FOUND); } let config = ThemeConfig::default(); let sample_articles: Vec = (1..=6) .map(|i| ArticleData { id: format!("00000000-0000-0000-0000-00000000000{i}"), short_id: format!("0000000{i}"), title: format!("Eksempelartikkel {i}"), content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \ Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris." .to_string(), summary: Some("Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string()), published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }) .collect(); let index_data = IndexData { title: format!("Forhåndsvisning — {theme}"), description: Some("Eksempeldata for temavisning".to_string()), hero: Some(sample_articles[0].clone()), featured: sample_articles[1..4].to_vec(), stream: sample_articles[4..].to_vec(), }; let base_url = format!("/pub/{slug}"); let tera = build_tera(); let html = render_index(&tera, &theme, &config, &index_data, &base_url).map_err(|e| { tracing::error!(theme = %theme, error = %e, "Tera render-feil (preview)"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Response::builder() .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .header(header::CACHE_CONTROL, "no-cache") .body(html.into()) .unwrap()) } // ============================================================================= // Tester // ============================================================================= #[cfg(test)] mod tests { use super::*; #[test] fn css_variables_use_defaults() { let config = ThemeConfig::default(); let css = build_css_variables("avis", &config); assert!(css.contains("--color-primary: #1a1a2e")); assert!(css.contains("--font-heading: 'Georgia'")); assert!(css.contains("--layout-max-width: 1200px")); } #[test] fn css_variables_override() { let config = ThemeConfig { colors: ColorConfig { primary: Some("#ff0000".to_string()), ..Default::default() }, ..Default::default() }; let css = build_css_variables("blogg", &config); assert!(css.contains("--color-primary: #ff0000")); // Andre verdier skal fortsatt bruke blogg-defaults assert!(css.contains("--color-accent: #3498db")); } #[test] fn tera_builds_successfully() { let tera = build_tera(); // Alle 8 tema-templates + base skal finnes let templates: Vec<&str> = tera.get_template_names().collect(); assert!(templates.contains(&"base.html")); assert!(templates.contains(&"avis/article.html")); assert!(templates.contains(&"avis/index.html")); assert!(templates.contains(&"magasin/article.html")); assert!(templates.contains(&"magasin/index.html")); assert!(templates.contains(&"blogg/article.html")); assert!(templates.contains(&"blogg/index.html")); assert!(templates.contains(&"tidsskrift/article.html")); assert!(templates.contains(&"tidsskrift/index.html")); } #[test] fn render_article_all_themes() { let tera = build_tera(); let config = ThemeConfig::default(); let article = ArticleData { id: "test-id".to_string(), short_id: "test-sho".to_string(), title: "Testittel".to_string(), content: "

Testinnhold

".to_string(), summary: Some("Kort oppsummering".to_string()), published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }; for theme in &["avis", "magasin", "blogg", "tidsskrift"] { let html = render_article(&tera, theme, &config, &article, "Testsamling", "/pub/test") .unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}")); assert!(html.contains("Testittel"), "Tittel mangler i {theme}"); assert!(html.contains("Testinnhold"), "Innhold mangler i {theme}"); assert!(html.contains("--color-primary"), "CSS-variabler mangler i {theme}"); } } #[test] fn render_index_all_themes() { let tera = build_tera(); let config = ThemeConfig::default(); let index = IndexData { title: "Testforside".to_string(), description: None, hero: None, featured: vec![], stream: vec![ArticleData { id: "s1".to_string(), short_id: "s1000000".to_string(), title: "Strøm-artikkel".to_string(), content: "Innhold".to_string(), summary: Some("Sammendrag".to_string()), published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }], }; for theme in &["avis", "magasin", "blogg", "tidsskrift"] { let html = render_index(&tera, theme, &config, &index, "/pub/test") .unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}")); assert!(html.contains("Testforside"), "Tittel mangler i {theme}"); assert!(html.contains("Strøm-artikkel"), "Strøm-artikkel mangler i {theme}"); } } #[test] fn unknown_theme_falls_back_to_blogg() { let config = ThemeConfig::default(); let css = build_css_variables("nonexistent", &config); let css_blogg = build_css_variables("blogg", &config); assert_eq!(css, css_blogg); } }