// synops-render — Tera HTML-rendering til CAS. // // Rendrer artikler, forsider og om-sider til HTML via Tera-templates, // lagrer i CAS (content-addressable store), og oppdaterer node metadata. // Erstatter rendering-logikken i maskinrommet/src/publishing.rs. // // Miljøvariabler: // DATABASE_URL — PostgreSQL-tilkobling (påkrevd med --write) // CAS_ROOT — Rot for content-addressable store (default: /srv/synops/media/cas) // // Ref: docs/retninger/unix_filosofi.md, docs/concepts/publisering.md use chrono::{DateTime, Utc}; use clap::Parser; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::process; use tera::{Context, Tera}; use uuid::Uuid; /// Renderer-versjon. Økes ved mal-/template-endringer. const RENDERER_VERSION: i64 = 2; // ============================================================================= // CLI // ============================================================================= /// Render artikkel/forside/om-side til HTML og lagre i CAS. #[derive(Parser)] #[command(name = "synops-render", about = "Tera HTML-rendering til CAS")] struct Cli { /// Node-ID å rendere (artikkel eller samling) #[arg(long)] node_id: Uuid, /// Tema (avis, magasin, blogg, tidsskrift). Overstyrer samlingens konfig. #[arg(long)] theme: Option, /// Samlings-ID (påkrevd for artikkel-rendering, brukes for tema/slug-oppslag) #[arg(long)] collection_id: Option, /// Render-type: article, index, about (default: article) #[arg(long, default_value = "article")] render_type: String, /// Bruker-ID som utløste renderingen (for ressurslogging) #[arg(long)] requested_by: Option, /// Skriv resultater til database (uten dette flagget: kun stdout) #[arg(long)] write: bool, } // ============================================================================= // Datamodeller // ============================================================================= #[derive(Deserialize, Default, Debug)] struct PublishingConfig { slug: Option, theme: Option, #[serde(default)] theme_config: ThemeConfig, custom_domain: Option, featured_max: Option, stream_page_size: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] struct ThemeConfig { #[serde(default)] colors: ColorConfig, #[serde(default)] typography: TypographyConfig, #[serde(default)] layout: LayoutConfig, logo_hash: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] struct ColorConfig { primary: Option, accent: Option, background: Option, text: Option, muted: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] struct TypographyConfig { heading_font: Option, body_font: Option, } #[derive(Deserialize, Default, Debug, Clone, Serialize)] struct LayoutConfig { max_width: Option, } #[derive(Serialize, Clone)] struct ArticleData { id: String, short_id: String, title: String, subtitle: Option, content: String, summary: Option, og_image: Option, published_at: String, published_at_short: String, } #[derive(Serialize)] struct IndexData { title: String, description: Option, hero: Option, featured: Vec, stream: Vec, } #[derive(Serialize, Clone)] struct SeoData { og_title: String, description: String, canonical_url: String, og_image: Option, json_ld: String, } #[derive(Serialize)] struct RenderResult { html_hash: String, size: u64, renderer_version: i64, render_type: String, } // ============================================================================= // Presentasjonselementer // ============================================================================= struct PresEl { title: Option, content: Option, metadata: serde_json::Value, edge_metadata: serde_json::Value, } impl PresEl { fn ab_status(&self) -> &str { self.edge_metadata .get("ab_status") .and_then(|v| v.as_str()) .unwrap_or("") } } struct PresentationElements { titles: Vec, subtitles: Vec, summaries: Vec, og_images: Vec, } impl PresentationElements { fn best_of(elements: &[PresEl]) -> Option<&PresEl> { if let Some(el) = elements.iter().find(|e| e.ab_status() == "winner") { return Some(el); } elements.iter().find(|e| e.ab_status() != "retired") } fn best_title(&self) -> Option { Self::best_of(&self.titles) .and_then(|el| el.title.clone().or(el.content.clone())) } fn best_subtitle(&self) -> Option { Self::best_of(&self.subtitles) .and_then(|el| el.title.clone().or(el.content.clone())) } fn best_summary(&self) -> Option { Self::best_of(&self.summaries) .and_then(|el| el.content.clone().or(el.title.clone())) } fn best_og_image(&self) -> Option { Self::best_of(&self.og_images) .and_then(|el| el.metadata.get("cas_hash").and_then(|h| h.as_str()).map(|s| s.to_string())) } } // ============================================================================= // Tema-defaults og CSS-variabler // ============================================================================= 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", }, _ => theme_defaults("blogg"), } } 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-templates (innebygde) // ============================================================================= fn build_tera() -> Tera { let mut tera = Tera::default(); tera.add_raw_template("base.html", include_str!("templates/base.html")) .expect("Feil i base.html template"); 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"); 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"); 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"); 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.add_raw_template("category.html", include_str!("templates/category.html")) .expect("Feil i category.html"); tera.add_raw_template("archive.html", include_str!("templates/archive.html")) .expect("Feil i archive.html"); tera.add_raw_template("search.html", include_str!("templates/search.html")) .expect("Feil i search.html"); tera.add_raw_template("about.html", include_str!("templates/about.html")) .expect("Feil i about.html"); tera } // ============================================================================= // SEO // ============================================================================= 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); 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("") }); ld.to_string() } // ============================================================================= // TipTap JSON → HTML // ============================================================================= mod tiptap; // ============================================================================= // CAS-lagring // ============================================================================= async fn store_in_cas(root: &str, data: &[u8]) -> Result<(String, u64, bool), String> { let hash = synops_common::cas::hash_bytes(data); let size = data.len() as u64; let path = synops_common::cas::path(root, &hash); if path.exists() { return Ok((hash, size, true)); } if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("Kunne ikke opprette CAS-katalog: {e}"))?; } // Skriv via temp-fil for atomisk operasjon let tmp_dir = PathBuf::from(root).join("tmp"); tokio::fs::create_dir_all(&tmp_dir) .await .map_err(|e| format!("Kunne ikke opprette CAS tmp-katalog: {e}"))?; let tmp_path = tmp_dir.join(format!("{hash}.tmp")); tokio::fs::write(&tmp_path, data) .await .map_err(|e| format!("Kunne ikke skrive til CAS tmp-fil: {e}"))?; tokio::fs::rename(&tmp_path, &path) .await .map_err(|e| format!("Atomisk rename feilet: {e}"))?; Ok((hash, size, false)) } // ============================================================================= // Hjelpefunksjoner // ============================================================================= 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]), } } // ============================================================================= // Render-logikk // ============================================================================= fn render_article_html( tera: &Tera, theme: &str, config: &ThemeConfig, article: &ArticleData, collection_title: &str, base_url: &str, seo: &SeoData, has_rss: bool, ) -> 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); ctx.insert("seo", seo); ctx.insert("has_rss", &has_rss); tera.render(&template_name, &ctx) .map_err(|e| format!("Tera render-feil: {e}")) } fn render_index_html( tera: &Tera, theme: &str, config: &ThemeConfig, index: &IndexData, base_url: &str, has_rss: bool, ) -> 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("collection_title", &index.title); ctx.insert("base_url", base_url); ctx.insert("logo_hash", &config.logo_hash); ctx.insert("has_rss", &has_rss); tera.render(&template_name, &ctx) .map_err(|e| format!("Tera render-feil (index): {e}")) } // ============================================================================= // Database-oppslag // ============================================================================= async fn fetch_collection_config( db: &sqlx::PgPool, collection_id: Uuid, ) -> Result<(String, PublishingConfig, bool), String> { let row: Option<(Option, serde_json::Value)> = sqlx::query_as( r#" SELECT title, metadata FROM nodes WHERE id = $1 AND node_kind = 'collection' "#, ) .bind(collection_id) .fetch_optional(db) .await .map_err(|e| format!("Feil ved henting av samling: {e}"))?; let Some((title_opt, metadata)) = row else { return Err(format!("Samling {collection_id} finnes ikke")); }; let traits = metadata.get("traits"); let config: PublishingConfig = traits .and_then(|t| t.get("publishing")) .cloned() .map(|v| serde_json::from_value(v).unwrap_or_default()) .unwrap_or_default(); let has_rss = traits.and_then(|t| t.get("rss")).is_some(); let slug = config.slug.as_deref().unwrap_or("unknown"); let title = title_opt.unwrap_or_else(|| slug.to_string()); Ok((title, config, has_rss)) } /// Finn samlings-ID for en artikkel via belongs_to-edge. async fn find_collection_for_article( db: &sqlx::PgPool, node_id: Uuid, ) -> Result, String> { let row: Option<(Uuid,)> = sqlx::query_as( r#" SELECT e.target_id FROM edges e JOIN nodes n ON n.id = e.target_id AND n.node_kind = 'collection' WHERE e.source_id = $1 AND e.edge_type = 'belongs_to' AND n.metadata->'traits' ? 'publishing' LIMIT 1 "#, ) .bind(node_id) .fetch_optional(db) .await .map_err(|e| format!("Feil ved oppslag av samling for artikkel: {e}"))?; Ok(row.map(|(id,)| id)) } async fn fetch_presentation_elements( db: &sqlx::PgPool, article_id: Uuid, ) -> Result { let rows: Vec<(String, Option, Option, serde_json::Value, serde_json::Value)> = sqlx::query_as( r#" SELECT e.edge_type, n.title, n.content, n.metadata, e.metadata AS edge_metadata FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = $1 AND e.edge_type IN ('title', 'subtitle', 'summary', 'og_image') ORDER BY e.created_at "#, ) .bind(article_id) .fetch_all(db) .await .map_err(|e| format!("Feil ved henting av presentasjonselementer: {e}"))?; let mut titles = vec![]; let mut subtitles = vec![]; let mut summaries = vec![]; let mut og_images = vec![]; for (edge_type, title, content, metadata, edge_metadata) in rows { let el = PresEl { title, content, metadata, edge_metadata }; match edge_type.as_str() { "title" => titles.push(el), "subtitle" => subtitles.push(el), "summary" => summaries.push(el), "og_image" => og_images.push(el), _ => {} } } Ok(PresentationElements { titles, subtitles, summaries, og_images }) } // ============================================================================= // Artikkel-rendering // ============================================================================= async fn render_article_to_cas( db: &sqlx::PgPool, cas_root: &str, node_id: Uuid, collection_id: Uuid, theme_override: Option<&str>, write: bool, ) -> Result { let (collection_title, pub_config, has_rss) = fetch_collection_config(db, collection_id).await?; let theme = theme_override .or(pub_config.theme.as_deref()) .unwrap_or("blogg"); let config = &pub_config.theme_config; let slug = pub_config.slug.as_deref().unwrap_or("unknown"); // Hent artikkeldata let article_row: Option<(Uuid, Option, Option, serde_json::Value, DateTime)> = sqlx::query_as( "SELECT id, title, content, metadata, created_at FROM nodes WHERE id = $1", ) .bind(node_id) .fetch_optional(db) .await .map_err(|e| format!("Feil ved henting av artikkel: {e}"))?; let Some((id, title, content, metadata, created_at)) = article_row else { return Err(format!("Artikkel {node_id} finnes ikke")); }; // Hent publish_at fra edge-metadata let edge_meta: Option<(Option,)> = sqlx::query_as( r#" SELECT metadata FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'belongs_to' LIMIT 1 "#, ) .bind(node_id) .bind(collection_id) .fetch_optional(db) .await .map_err(|e| format!("Feil ved henting av edge: {e}"))?; let publish_at = edge_meta .as_ref() .and_then(|(m,)| m.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); // Konverter TipTap JSON → HTML let article_html = if let Some(doc) = metadata.get("document") { let html = tiptap::document_to_html(doc); if html.is_empty() { content.unwrap_or_default() } else { html } } else { content.unwrap_or_default() }; let short_id = id.to_string()[..8].to_string(); // Hent presentasjonselementer let pres = fetch_presentation_elements(db, node_id).await?; let article_title = pres.best_title() .unwrap_or_else(|| title.unwrap_or_else(|| "Uten tittel".to_string())); let summary_text = pres.best_summary() .unwrap_or_else(|| truncate(&article_html.replace("

", "").replace("

", " ").replace('\n', " "), 200)); let article_data = ArticleData { id: id.to_string(), short_id: short_id.clone(), title: article_title, subtitle: pres.best_subtitle(), content: article_html, summary: Some(summary_text), og_image: pres.best_og_image(), published_at: publish_at.to_rfc3339(), published_at_short: publish_at.format("%e. %B %Y").to_string(), }; // SEO + render let base_url = pub_config .custom_domain .as_deref() .map(|d| format!("https://{d}")) .unwrap_or_else(|| format!("/pub/{slug}")); let canonical_url = format!("{base_url}/{short_id}"); let seo = build_seo_data(&article_data, &collection_title, &canonical_url); let tera = build_tera(); let html = render_article_html(&tera, theme, config, &article_data, &collection_title, &base_url, &seo, has_rss)?; // Lagre i CAS let (hash, size, deduplicated) = store_in_cas(cas_root, html.as_bytes()).await?; tracing::info!( node_id = %node_id, hash = %hash, size = size, deduplicated = deduplicated, "Artikkel rendret og lagret i CAS" ); // Oppdater metadata i PG if write { let now = Utc::now(); sqlx::query( r#" UPDATE nodes SET metadata = jsonb_set( jsonb_set( jsonb_set( CASE WHEN metadata ? 'rendered' THEN metadata ELSE jsonb_set(metadata, '{rendered}', '{}'::jsonb) END, '{rendered,html_hash}', to_jsonb($2::text) ), '{rendered,rendered_at}', to_jsonb($3::text) ), '{rendered,renderer_version}', to_jsonb($4::bigint) ) WHERE id = $1 "#, ) .bind(node_id) .bind(&hash) .bind(now.to_rfc3339()) .bind(RENDERER_VERSION) .execute(db) .await .map_err(|e| format!("Feil ved oppdatering av metadata.rendered: {e}"))?; tracing::info!(node_id = %node_id, html_hash = %hash, "metadata.rendered oppdatert"); // Ressurslogging log_resource_usage(db, node_id, size, "render_article").await; } Ok(RenderResult { html_hash: hash, size, renderer_version: RENDERER_VERSION, render_type: "article".to_string(), }) } // ============================================================================= // Index-rendering // ============================================================================= async fn render_index_to_cas( db: &sqlx::PgPool, cas_root: &str, collection_id: Uuid, theme_override: Option<&str>, write: bool, ) -> Result { let (collection_title, pub_config, has_rss) = fetch_collection_config(db, collection_id).await?; let theme = theme_override .or(pub_config.theme.as_deref()) .unwrap_or("blogg"); let config = &pub_config.theme_config; let slug = pub_config.slug.as_deref().unwrap_or("unknown"); let featured_max = pub_config.featured_max.unwrap_or(4); let stream_page_size = pub_config.stream_page_size.unwrap_or(20); let base_url = pub_config .custom_domain .as_deref() .map(|d| format!("https://{d}")) .unwrap_or_else(|| format!("/pub/{slug}")); // Hent artikler for forsiden let articles = fetch_index_articles(db, collection_id, featured_max, stream_page_size).await?; let (hero, featured, stream) = categorize_articles(articles); let index_data = IndexData { title: collection_title, description: None, hero, featured, stream, }; let tera = build_tera(); let html = render_index_html(&tera, theme, config, &index_data, &base_url, has_rss)?; let (hash, size, deduplicated) = store_in_cas(cas_root, html.as_bytes()).await?; tracing::info!( collection_id = %collection_id, hash = %hash, size = size, deduplicated = deduplicated, "Forside rendret og lagret i CAS" ); if write { let now = Utc::now(); sqlx::query( r#" UPDATE nodes SET metadata = jsonb_set( jsonb_set( jsonb_set( CASE WHEN metadata ? 'rendered_index' THEN metadata ELSE jsonb_set(metadata, '{rendered_index}', '{}'::jsonb) END, '{rendered_index,index_hash}', to_jsonb($2::text) ), '{rendered_index,rendered_at}', to_jsonb($3::text) ), '{rendered_index,renderer_version}', to_jsonb($4::bigint) ) WHERE id = $1 "#, ) .bind(collection_id) .bind(&hash) .bind(now.to_rfc3339()) .bind(RENDERER_VERSION) .execute(db) .await .map_err(|e| format!("Feil ved oppdatering av metadata.rendered_index: {e}"))?; tracing::info!(collection_id = %collection_id, index_hash = %hash, "metadata.rendered_index oppdatert"); log_resource_usage(db, collection_id, size, "render_index").await; } Ok(RenderResult { html_hash: hash, size, renderer_version: RENDERER_VERSION, render_type: "index".to_string(), }) } /// Hent publiserte artikler for en samling, sortert etter publish_at desc. async fn fetch_index_articles( db: &sqlx::PgPool, collection_id: Uuid, featured_max: i64, stream_page_size: i64, ) -> Result)>, String> { // Hent artikler med slot-metadata fra edge let limit = 1 + featured_max + stream_page_size; let rows: Vec<(Uuid, Option, Option, serde_json::Value, DateTime, Option)> = sqlx::query_as( r#" SELECT n.id, n.title, n.content, n.metadata, n.created_at, e.metadata AS edge_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.node_kind IN ('article', 'note') ORDER BY COALESCE((e.metadata->>'publish_at')::timestamptz, n.created_at) DESC LIMIT $2 "#, ) .bind(collection_id) .bind(limit) .fetch_all(db) .await .map_err(|e| format!("Feil ved henting av forsideartikler: {e}"))?; let mut articles = Vec::new(); for (id, title, content, metadata, created_at, edge_metadata) in rows { let publish_at = edge_metadata .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 slot = edge_metadata .as_ref() .and_then(|m| m.get("slot")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); let article_html = if let Some(doc) = metadata.get("document") { let html = tiptap::document_to_html(doc); if html.is_empty() { content.unwrap_or_default() } else { html } } else { content.unwrap_or_default() }; let short_id = id.to_string()[..8].to_string(); let summary_text = truncate( &article_html.replace("

", "").replace("

", " ").replace('\n', " "), 200, ); let article_data = ArticleData { id: id.to_string(), short_id, title: title.unwrap_or_else(|| "Uten tittel".to_string()), subtitle: None, content: article_html, summary: Some(summary_text), og_image: None, published_at: publish_at.to_rfc3339(), published_at_short: publish_at.format("%e. %B %Y").to_string(), }; articles.push((article_data, slot)); } Ok(articles) } /// Fordel artikler i hero/featured/stream basert på slot-metadata. fn categorize_articles( articles: Vec<(ArticleData, Option)>, ) -> (Option, Vec, Vec) { let mut hero: Option = None; let mut featured = Vec::new(); let mut stream = Vec::new(); for (article, slot) in articles { match slot.as_deref() { Some("hero") if hero.is_none() => hero = Some(article), Some("featured") => featured.push(article), _ => stream.push(article), } } // Hvis ingen hero er satt, bruk første stream-artikkel if hero.is_none() && !stream.is_empty() { hero = Some(stream.remove(0)); } (hero, featured, stream) } // ============================================================================= // Ressurslogging // ============================================================================= async fn log_resource_usage(db: &sqlx::PgPool, node_id: Uuid, bytes: u64, operation: &str) { let _ = sqlx::query( r#" INSERT INTO resource_usage_log (node_id, resource_type, amount, unit, metadata) VALUES ($1, $2, $3, 'bytes', $4) "#, ) .bind(node_id) .bind(operation) .bind(bytes as i64) .bind(serde_json::json!({"renderer_version": RENDERER_VERSION})) .execute(db) .await; } // ============================================================================= // main // ============================================================================= #[tokio::main] async fn main() { synops_common::logging::init("synops_render"); let cli = Cli::parse(); // Valider render_type if !["article", "index"].contains(&cli.render_type.as_str()) { eprintln!("Ugyldig render-type: {}. Bruk: article, index", cli.render_type); process::exit(1); } // Koble til database let db = synops_common::db::connect().await.unwrap_or_else(|e| { eprintln!("{e}"); process::exit(1); }); let cas_root = synops_common::cas::root(); let result = match cli.render_type.as_str() { "article" => { // Finn collection_id: brukt direkte, eller oppslag via belongs_to let collection_id = match cli.collection_id { Some(id) => id, None => { match find_collection_for_article(&db, cli.node_id).await { Ok(Some(id)) => id, Ok(None) => { eprintln!("Ingen publishing-samling funnet for node {}. Bruk --collection-id.", cli.node_id); process::exit(1); } Err(e) => { eprintln!("{e}"); process::exit(1); } } } }; render_article_to_cas( &db, &cas_root, cli.node_id, collection_id, cli.theme.as_deref(), cli.write, ) .await } "index" => { render_index_to_cas( &db, &cas_root, cli.node_id, cli.theme.as_deref(), cli.write, ) .await } _ => unreachable!(), }; match result { Ok(res) => { let json = serde_json::to_string_pretty(&res).unwrap(); println!("{json}"); } Err(e) => { eprintln!("Render feilet: {e}"); process::exit(1); } } } // ============================================================================= // Tester // ============================================================================= #[cfg(test)] mod tests { use super::*; #[test] fn test_build_css_variables_defaults() { let config = ThemeConfig::default(); let css = build_css_variables("blogg", &config); assert!(css.contains("--color-primary: #2c3e50")); assert!(css.contains("--color-accent: #3498db")); assert!(css.contains("--layout-max-width: 720px")); } #[test] fn test_build_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 bruker defaults assert!(css.contains("--color-accent: #3498db")); } #[test] fn test_theme_defaults_fallback() { let defaults = theme_defaults("ukjent_tema"); // Skal falle tilbake til blogg assert_eq!(defaults.primary, "#2c3e50"); } #[test] fn test_truncate() { assert_eq!(truncate("kort", 10), "kort"); assert_eq!(truncate("dette er en lang tekst som skal kuttes", 20), "dette er en lang…"); } #[test] fn test_cas_path() { let path = synops_common::cas::path("/srv/synops/media/cas", "b94d27b9934d3e08"); assert!(path.to_string_lossy().contains("/b9/4d/")); } #[test] fn test_build_seo_data() { let article = ArticleData { id: "test-id".to_string(), short_id: "test-sho".to_string(), title: "Testittel".to_string(), subtitle: None, content: "

Innhold

".to_string(), summary: Some("Sammendrag".to_string()), og_image: Some("abc123".to_string()), published_at: "2024-01-01T00:00:00Z".to_string(), published_at_short: " 1. January 2024".to_string(), }; let seo = build_seo_data(&article, "Testpub", "/pub/test/test-sho"); assert_eq!(seo.og_title, "Testittel"); assert_eq!(seo.description, "Sammendrag"); assert!(seo.json_ld.contains("\"@type\":\"Article\"")); assert_eq!(seo.og_image, Some("/cas/abc123".to_string())); } #[test] fn test_categorize_articles() { let hero_art = ArticleData { id: "1".into(), short_id: "1".into(), title: "Hero".into(), subtitle: None, content: "".into(), summary: None, og_image: None, published_at: "".into(), published_at_short: "".into(), }; let feat_art = ArticleData { id: "2".into(), short_id: "2".into(), title: "Featured".into(), subtitle: None, content: "".into(), summary: None, og_image: None, published_at: "".into(), published_at_short: "".into(), }; let stream_art = ArticleData { id: "3".into(), short_id: "3".into(), title: "Stream".into(), subtitle: None, content: "".into(), summary: None, og_image: None, published_at: "".into(), published_at_short: "".into(), }; let articles = vec![ (hero_art, Some("hero".to_string())), (feat_art, Some("featured".to_string())), (stream_art, None), ]; let (hero, featured, stream) = categorize_articles(articles); assert!(hero.is_some()); assert_eq!(hero.unwrap().title, "Hero"); assert_eq!(featured.len(), 1); assert_eq!(stream.len(), 1); } #[test] fn test_categorize_articles_auto_hero() { let art = ArticleData { id: "1".into(), short_id: "1".into(), title: "Auto hero".into(), subtitle: None, content: "".into(), summary: None, og_image: None, published_at: "".into(), published_at_short: "".into(), }; let articles = vec![(art, None)]; let (hero, _, stream) = categorize_articles(articles); assert!(hero.is_some()); assert_eq!(hero.unwrap().title, "Auto hero"); assert!(stream.is_empty()); } #[test] fn test_render_article_html() { let tera = build_tera(); let config = ThemeConfig::default(); let article = ArticleData { id: "test".into(), short_id: "test".into(), title: "Test".into(), subtitle: None, content: "

Hello

".into(), summary: Some("Sum".into()), og_image: None, published_at: "2024-01-01T00:00:00Z".into(), published_at_short: "1. jan 2024".into(), }; let seo = build_seo_data(&article, "Pub", "/pub/test/test"); let html = render_article_html(&tera, "blogg", &config, &article, "Pub", "/pub/test", &seo, false); assert!(html.is_ok()); let html = html.unwrap(); assert!(html.contains("Test")); // tittel assert!(html.contains("

Hello

")); // innhold assert!(html.contains("--color-primary")); // CSS-variabler } #[test] fn test_render_index_html() { let tera = build_tera(); let config = ThemeConfig::default(); let index = IndexData { title: "Forside".to_string(), description: None, hero: Some(ArticleData { id: "1".into(), short_id: "1".into(), title: "Hero".into(), subtitle: None, content: "

Hero content

".into(), summary: Some("Hero sum".into()), og_image: None, published_at: "2024-01-01T00:00:00Z".into(), published_at_short: "1. jan".into(), }), featured: vec![], stream: vec![], }; let html = render_index_html(&tera, "blogg", &config, &index, "/pub/test", false); assert!(html.is_ok()); let html = html.unwrap(); assert!(html.contains("Hero")); assert!(html.contains("Forside")); } #[test] fn test_presentation_elements_empty() { let pres = PresentationElements { titles: vec![], subtitles: vec![], summaries: vec![], og_images: vec![], }; assert!(pres.best_title().is_none()); assert!(pres.best_subtitle().is_none()); assert!(pres.best_summary().is_none()); assert!(pres.best_og_image().is_none()); } }