synops/tools/synops-render/src/main.rs
vegard 6496434bd3 synops-common: delt lib for alle CLI-verktøy (oppgave 21.16)
Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:

- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs

Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
2026-03-18 10:51:40 +00:00

1208 lines
39 KiB
Rust

// 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<String>,
/// Samlings-ID (påkrevd for artikkel-rendering, brukes for tema/slug-oppslag)
#[arg(long)]
collection_id: Option<Uuid>,
/// 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<Uuid>,
/// Skriv resultater til database (uten dette flagget: kun stdout)
#[arg(long)]
write: bool,
}
// =============================================================================
// Datamodeller
// =============================================================================
#[derive(Deserialize, Default, Debug)]
struct PublishingConfig {
slug: Option<String>,
theme: Option<String>,
#[serde(default)]
theme_config: ThemeConfig,
custom_domain: Option<String>,
featured_max: Option<i64>,
stream_page_size: Option<i64>,
}
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct ThemeConfig {
#[serde(default)]
colors: ColorConfig,
#[serde(default)]
typography: TypographyConfig,
#[serde(default)]
layout: LayoutConfig,
logo_hash: Option<String>,
}
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct ColorConfig {
primary: Option<String>,
accent: Option<String>,
background: Option<String>,
text: Option<String>,
muted: Option<String>,
}
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct TypographyConfig {
heading_font: Option<String>,
body_font: Option<String>,
}
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct LayoutConfig {
max_width: Option<String>,
}
#[derive(Serialize, Clone)]
struct ArticleData {
id: String,
short_id: String,
title: String,
subtitle: Option<String>,
content: String,
summary: Option<String>,
og_image: Option<String>,
published_at: String,
published_at_short: String,
}
#[derive(Serialize)]
struct IndexData {
title: String,
description: Option<String>,
hero: Option<ArticleData>,
featured: Vec<ArticleData>,
stream: Vec<ArticleData>,
}
#[derive(Serialize, Clone)]
struct SeoData {
og_title: String,
description: String,
canonical_url: String,
og_image: Option<String>,
json_ld: String,
}
#[derive(Serialize)]
struct RenderResult {
html_hash: String,
size: u64,
renderer_version: i64,
render_type: String,
}
// =============================================================================
// Presentasjonselementer
// =============================================================================
struct PresEl {
title: Option<String>,
content: Option<String>,
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<PresEl>,
subtitles: Vec<PresEl>,
summaries: Vec<PresEl>,
og_images: Vec<PresEl>,
}
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<String> {
Self::best_of(&self.titles)
.and_then(|el| el.title.clone().or(el.content.clone()))
}
fn best_subtitle(&self) -> Option<String> {
Self::best_of(&self.subtitles)
.and_then(|el| el.title.clone().or(el.content.clone()))
}
fn best_summary(&self) -> Option<String> {
Self::best_of(&self.summaries)
.and_then(|el| el.content.clone().or(el.title.clone()))
}
fn best_og_image(&self) -> Option<String> {
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<String, String> {
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<String, String> {
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<String>, 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<Option<Uuid>, 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<PresentationElements, String> {
let rows: Vec<(String, Option<String>, Option<String>, 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<RenderResult, String> {
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<String>, Option<String>, serde_json::Value, DateTime<Utc>)> = 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<serde_json::Value>,)> = 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::<DateTime<Utc>>().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("<p>", "").replace("</p>", " ").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<RenderResult, String> {
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<Vec<(ArticleData, Option<String>)>, String> {
// Hent artikler med slot-metadata fra edge
let limit = 1 + featured_max + stream_page_size;
let rows: Vec<(Uuid, Option<String>, Option<String>, serde_json::Value, DateTime<Utc>, Option<serde_json::Value>)> = 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::<DateTime<Utc>>().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("<p>", "").replace("</p>", " ").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<String>)>,
) -> (Option<ArticleData>, Vec<ArticleData>, Vec<ArticleData>) {
let mut hero: Option<ArticleData> = 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: "<p>Innhold</p>".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: "<p>Hello</p>".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("<p>Hello</p>")); // 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: "<p>Hero content</p>".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());
}
}