synops/maskinrommet/src/publishing.rs
vegard 4b9f520eab Tera-templates: innebygde temaer for publisering (oppgave 14.1)
Implementerer publiseringsmotoren med fire innebygde temaer:
- Avis: multi-kolonne, informasjonstung, hero+sidebar+rutenett
- Magasin: store bilder, luft, editorial, cards-layout
- Blogg: enkel, én kolonne, kronologisk liste
- Tidsskrift: akademisk, tekstdrevet, nummerert innholdsfortegnelse

Hvert tema har artikkelmal + forside-mal som Tera-templates (Jinja2-like).
CSS-variabler for theme_config-overstyring fra publishing-traiten —
fungerer meningsfullt med bare "theme": "magasin" (null konfigurasjon).

Teknisk:
- publishing.rs: Tera engine, render-funksjoner, DB-spørringer, HTTP-handlers
- Templates innebygd via include_str! (kompilert inn i binæren)
- Ruter: GET /pub/{slug} (forside), /pub/{slug}/{id} (artikkel),
  /pub/{slug}/preview/{theme} (forhåndsvisning med testdata)
- 6 enhetstester for CSS-variabler, rendering og tema-fallback

Ref: docs/concepts/publisering.md § "Temaer"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:41:54 +00:00

709 lines
24 KiB
Rust

//! 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<String>,
pub theme: Option<String>,
#[serde(default)]
pub theme_config: ThemeConfig,
pub custom_domain: Option<String>,
pub index_mode: Option<String>,
pub featured_max: Option<i64>,
pub stream_page_size: Option<i64>,
}
#[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<String>,
}
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
pub struct ColorConfig {
pub primary: Option<String>,
pub accent: Option<String>,
pub background: Option<String>,
pub text: Option<String>,
pub muted: Option<String>,
}
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
pub struct TypographyConfig {
pub heading_font: Option<String>,
pub body_font: Option<String>,
}
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
pub struct LayoutConfig {
pub max_width: Option<String>,
}
// =============================================================================
// 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<String>,
pub published_at: String,
pub published_at_short: String,
}
#[derive(Serialize)]
pub struct IndexData {
pub title: String,
pub description: Option<String>,
pub hero: Option<ArticleData>,
pub featured: Vec<ArticleData>,
pub stream: Vec<ArticleData>,
}
// =============================================================================
// 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<String, tera::Error> {
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<String, tera::Error> {
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<String>,
publishing_config: PublishingConfig,
}
/// Finn samling med publishing-trait basert på slug.
async fn find_publishing_collection(
db: &PgPool,
slug: &str,
) -> Result<Option<CollectionRow>, sqlx::Error> {
let row: Option<(Uuid, Option<String>, 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<Option<(ArticleData, Option<serde_json::Value>)>, sqlx::Error> {
// short_id er de første 8 tegnene av UUID
let pattern = format!("{article_short_id}%");
let row: Option<(Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>)> = 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::<DateTime<Utc>>().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<ArticleData>, Vec<ArticleData>, Vec<ArticleData>), sqlx::Error> {
// Hent alle publiserte artikler med edge-metadata
let rows: Vec<(Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>)> = 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<ArticleData> = None;
let mut featured: Vec<ArticleData> = Vec::new();
let mut stream: Vec<ArticleData> = 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::<DateTime<Utc>>().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<AppState>,
Path(slug): Path<String>,
) -> Result<Response, StatusCode> {
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<AppState>,
Path((slug, article_id)): Path<(String, String)>,
) -> Result<Response, StatusCode> {
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<Response, StatusCode> {
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<ArticleData> = (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: "<p>Testinnhold</p>".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);
}
}