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.
1208 lines
39 KiB
Rust
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());
|
|
}
|
|
}
|