//! Publiseringsmotor: Tera-templates med innebygde temaer. //! //! Fire temaer: avis, magasin, blogg, tidsskrift. //! Hvert tema har artikkelmal + forside-mal. //! CSS-variabler for theme_config-overstyring. //! //! Artikler rendres til HTML via Tera, lagres i CAS med SEO-metadata //! (OG-tags, canonical, JSON-LD). Noden oppdateres med //! `metadata.rendered.html_hash` + `renderer_version`. //! //! Ref: docs/concepts/publisering.md § "Temaer", "HTML-rendering og CAS" use std::collections::HashMap; use std::sync::Arc; use axum::{ extract::{Path, Query, State}, http::{header, StatusCode}, response::Response, }; use chrono::{DateTime, Datelike, Utc}; use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tera::{Context, Tera}; use tokio::sync::RwLock; use uuid::Uuid; use crate::cas::CasStore; use crate::jobs; use crate::tiptap; use crate::AppState; /// Renderer-versjon. Økes ved mal-/template-endringer. /// Brukes for å identifisere artikler som trenger re-rendering (oppgave 14.14). pub const RENDERER_VERSION: i64 = 2; // ============================================================================= // 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 index_cache_ttl: Option, pub featured_max: Option, pub stream_page_size: Option, /// Krever redaksjonell godkjenning for publisering. /// Når true: members bruker submitted_to-flyten, kun owner/admin kan opprette belongs_to. #[serde(default)] pub require_approval: bool, /// Roller som kan opprette submitted_to-edges til samlingen. /// Verdier: "owner", "admin", "member", "reader". Default: ["member"]. #[serde(default = "default_submission_roles")] pub submission_roles: Vec, } fn default_submission_roles() -> Vec { vec!["member".to_string()] } #[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, } // ============================================================================= // SEO-data // ============================================================================= /// SEO-metadata for artikkelrendering. #[derive(Serialize, Clone)] pub struct SeoData { pub og_title: String, pub description: String, pub canonical_url: String, pub og_image: Option, pub json_ld: String, } fn build_seo_data( article: &ArticleData, collection_title: &str, canonical_url: &str, ) -> SeoData { let description = article .summary .as_deref() .unwrap_or("") .to_string(); let json_ld = build_json_ld(article, collection_title, canonical_url); // Bygg OG-image URL fra CAS-hash hvis tilgjengelig let og_image = article.og_image.as_ref().map(|hash| format!("/cas/{hash}")); SeoData { og_title: article.title.clone(), description, canonical_url: canonical_url.to_string(), og_image, json_ld, } } fn build_json_ld( article: &ArticleData, publisher_name: &str, canonical_url: &str, ) -> String { let ld = serde_json::json!({ "@context": "https://schema.org", "@type": "Article", "headline": article.title, "datePublished": article.published_at, "url": canonical_url, "publisher": { "@type": "Organization", "name": publisher_name }, "description": article.summary.as_deref().unwrap_or("") }); // Escape sekvenser for sikker embedding i assert!(!ld.contains(""), "JSON-LD inneholder uescaped : {ld}"); assert!(ld.contains("<\\/script>"), "JSON-LD mangler escaped : {ld}"); } #[test] fn z_test_insufficient_data_returns_1() { // For lite data: returnerer p=1.0 (ingen signifikans) assert_eq!(z_test_proportions(10, 5, 10, 3), 1.0); } #[test] fn z_test_significant_difference() { // Variant A: 1000 imp, 100 klikk (10% CTR) // Variant B: 1000 imp, 50 klikk (5% CTR) let p = z_test_proportions(1000, 100, 1000, 50); assert!(p < 0.05, "Forventet signifikant forskjell, fikk p={p}"); } #[test] fn z_test_no_significant_difference() { // Variant A: 100 imp, 10 klikk (10% CTR) // Variant B: 100 imp, 9 klikk (9% CTR) let p = z_test_proportions(100, 10, 100, 9); assert!(p > 0.05, "Forventet ingen signifikant forskjell, fikk p={p}"); } #[test] fn json_ld_contains_required_fields() { let article = ArticleData { id: "test".to_string(), short_id: "test1234".to_string(), title: "Test-artikkel".to_string(), subtitle: None, content: "Innhold".to_string(), summary: Some("Oppsummering".to_string()), og_image: None, published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }; let ld = build_json_ld(&article, "Testpub", "https://example.com/test"); assert!(ld.contains("\"@type\":\"Article\"")); assert!(ld.contains("\"headline\":\"Test-artikkel\"")); assert!(ld.contains("\"datePublished\"")); assert!(ld.contains("\"publisher\"")); } }