// 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