//! 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 { // Escape for safe JSON embedding i