Når theme eller theme_config endres på en samling, trigges paginert bulk re-rendering av alle artikler (100 om gangen). Artikler serveres med gammelt tema til de er re-rendret — renderer_version identifiserer hvilke som gjenstår. Duplikatsjekk mot eksisterende pending/running jobber. - publishing.rs: trigger_bulk_rerender() med paginert SQL-query - intentions.rs: theme/theme_config endring detekteres i update_node - RENDERER_VERSION bumped til 2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1687 lines
56 KiB
Rust
1687 lines
56 KiB
Rust
//! Publiseringsmotor: Tera-templates med innebygde temaer.
|
|
//!
|
|
//! Fire temaer: avis, magasin, blogg, tidsskrift.
|
|
//! Hvert tema har artikkelmal + forside-mal.
|
|
//! CSS-variabler for theme_config-overstyring.
|
|
//!
|
|
//! Artikler rendres til HTML via Tera, lagres i CAS med SEO-metadata
|
|
//! (OG-tags, canonical, JSON-LD). Noden oppdateres med
|
|
//! `metadata.rendered.html_hash` + `renderer_version`.
|
|
//!
|
|
//! Ref: docs/concepts/publisering.md § "Temaer", "HTML-rendering og CAS"
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
extract::{Path, State},
|
|
http::{header, StatusCode},
|
|
response::Response,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::PgPool;
|
|
use tera::{Context, Tera};
|
|
use tokio::sync::RwLock;
|
|
use uuid::Uuid;
|
|
|
|
use crate::cas::CasStore;
|
|
use crate::jobs;
|
|
use crate::tiptap;
|
|
use crate::AppState;
|
|
|
|
/// Renderer-versjon. Økes ved mal-/template-endringer.
|
|
/// Brukes for å identifisere artikler som trenger re-rendering (oppgave 14.14).
|
|
pub const RENDERER_VERSION: i64 = 2;
|
|
|
|
// =============================================================================
|
|
// Tema-konfigurasjon fra publishing-trait
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize, Default, Debug)]
|
|
pub struct PublishingConfig {
|
|
pub slug: Option<String>,
|
|
pub theme: Option<String>,
|
|
#[serde(default)]
|
|
pub theme_config: ThemeConfig,
|
|
pub custom_domain: Option<String>,
|
|
pub index_mode: Option<String>,
|
|
pub index_cache_ttl: Option<u64>,
|
|
pub featured_max: Option<i64>,
|
|
pub stream_page_size: Option<i64>,
|
|
/// Krever redaksjonell godkjenning for publisering.
|
|
/// Når true: members bruker submitted_to-flyten, kun owner/admin kan opprette belongs_to.
|
|
#[serde(default)]
|
|
pub require_approval: bool,
|
|
/// Roller som kan opprette submitted_to-edges til samlingen.
|
|
/// Verdier: "owner", "admin", "member", "reader". Default: ["member"].
|
|
#[serde(default = "default_submission_roles")]
|
|
pub submission_roles: Vec<String>,
|
|
}
|
|
|
|
fn default_submission_roles() -> Vec<String> {
|
|
vec!["member".to_string()]
|
|
}
|
|
|
|
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
|
pub struct ThemeConfig {
|
|
#[serde(default)]
|
|
pub colors: ColorConfig,
|
|
#[serde(default)]
|
|
pub typography: TypographyConfig,
|
|
#[serde(default)]
|
|
pub layout: LayoutConfig,
|
|
pub logo_hash: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
|
pub struct ColorConfig {
|
|
pub primary: Option<String>,
|
|
pub accent: Option<String>,
|
|
pub background: Option<String>,
|
|
pub text: Option<String>,
|
|
pub muted: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
|
pub struct TypographyConfig {
|
|
pub heading_font: Option<String>,
|
|
pub body_font: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
|
pub struct LayoutConfig {
|
|
pub max_width: Option<String>,
|
|
}
|
|
|
|
// =============================================================================
|
|
// SEO-data
|
|
// =============================================================================
|
|
|
|
/// SEO-metadata for artikkelrendering.
|
|
#[derive(Serialize, Clone)]
|
|
pub struct SeoData {
|
|
pub og_title: String,
|
|
pub description: String,
|
|
pub canonical_url: String,
|
|
pub og_image: Option<String>,
|
|
pub json_ld: String,
|
|
}
|
|
|
|
fn build_seo_data(
|
|
article: &ArticleData,
|
|
collection_title: &str,
|
|
canonical_url: &str,
|
|
) -> SeoData {
|
|
let description = article
|
|
.summary
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
let json_ld = build_json_ld(article, collection_title, canonical_url);
|
|
|
|
SeoData {
|
|
og_title: article.title.clone(),
|
|
description,
|
|
canonical_url: canonical_url.to_string(),
|
|
og_image: None,
|
|
json_ld,
|
|
}
|
|
}
|
|
|
|
fn build_json_ld(
|
|
article: &ArticleData,
|
|
publisher_name: &str,
|
|
canonical_url: &str,
|
|
) -> String {
|
|
// Escape for safe JSON embedding i <script>-tag
|
|
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()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Innebygde temaer — Tera-templates
|
|
// =============================================================================
|
|
|
|
/// Tema-defaults for CSS-variabler per tema.
|
|
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",
|
|
},
|
|
// Fallback til blogg-defaults
|
|
_ => theme_defaults("blogg"),
|
|
}
|
|
}
|
|
|
|
/// Generer CSS-variabler fra theme_config med tema-defaults som fallback.
|
|
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 engine med innebygde templates
|
|
// =============================================================================
|
|
|
|
/// Bygg Tera-instans med alle innebygde temaer.
|
|
pub fn build_tera() -> Tera {
|
|
let mut tera = Tera::default();
|
|
|
|
// Base-template (felles for alle temaer)
|
|
tera.add_raw_template("base.html", include_str!("templates/base.html"))
|
|
.expect("Feil i base.html template");
|
|
|
|
// Avis
|
|
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");
|
|
|
|
// Magasin
|
|
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");
|
|
|
|
// Blogg
|
|
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");
|
|
|
|
// Tidsskrift
|
|
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
|
|
}
|
|
|
|
// =============================================================================
|
|
// Datamodeller for rendering
|
|
// =============================================================================
|
|
|
|
#[derive(Serialize, Clone)]
|
|
pub struct ArticleData {
|
|
pub id: String,
|
|
pub short_id: String,
|
|
pub title: String,
|
|
pub content: String,
|
|
pub summary: Option<String>,
|
|
pub published_at: String,
|
|
pub published_at_short: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct IndexData {
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub hero: Option<ArticleData>,
|
|
pub featured: Vec<ArticleData>,
|
|
pub stream: Vec<ArticleData>,
|
|
}
|
|
|
|
// =============================================================================
|
|
// In-memory index-cache (dynamisk modus)
|
|
// =============================================================================
|
|
|
|
/// Cachet forside-HTML med utløpstid.
|
|
pub struct CachedIndex {
|
|
html: String,
|
|
expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Thread-safe cache for forside-rendering (dynamisk modus).
|
|
/// Nøkkel: collection UUID. Verdi: rendret HTML med TTL.
|
|
pub type IndexCache = Arc<RwLock<HashMap<Uuid, CachedIndex>>>;
|
|
|
|
/// Opprett en ny tom IndexCache.
|
|
pub fn new_index_cache() -> IndexCache {
|
|
Arc::new(RwLock::new(HashMap::new()))
|
|
}
|
|
|
|
/// Invalider cache for en gitt samling.
|
|
pub async fn invalidate_index_cache(cache: &IndexCache, collection_id: Uuid) {
|
|
let mut map = cache.write().await;
|
|
if map.remove(&collection_id).is_some() {
|
|
tracing::info!(collection_id = %collection_id, "Forside-cache invalidert");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Render-funksjoner
|
|
// =============================================================================
|
|
|
|
/// Render en artikkel med gitt tema og SEO-metadata.
|
|
pub fn render_article(
|
|
tera: &Tera,
|
|
theme: &str,
|
|
config: &ThemeConfig,
|
|
article: &ArticleData,
|
|
collection_title: &str,
|
|
base_url: &str,
|
|
seo: &SeoData,
|
|
has_rss: bool,
|
|
) -> Result<String, tera::Error> {
|
|
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)
|
|
}
|
|
|
|
/// Render forsiden med gitt tema.
|
|
pub fn render_index(
|
|
tera: &Tera,
|
|
theme: &str,
|
|
config: &ThemeConfig,
|
|
index: &IndexData,
|
|
base_url: &str,
|
|
has_rss: bool,
|
|
) -> Result<String, tera::Error> {
|
|
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("base_url", base_url);
|
|
ctx.insert("logo_hash", &config.logo_hash);
|
|
ctx.insert("has_rss", &has_rss);
|
|
|
|
tera.render(&template_name, &ctx)
|
|
}
|
|
|
|
// =============================================================================
|
|
// CAS-rendering: render artikkel → lagre i CAS → oppdater node metadata
|
|
// =============================================================================
|
|
|
|
/// Render en artikkel til HTML, lagre i CAS, og oppdater nodens metadata.
|
|
///
|
|
/// Kalles fra jobbkø (`render_article`-jobb) når en `belongs_to`-edge
|
|
/// opprettes til en samling med `publishing`-trait.
|
|
///
|
|
/// Steg:
|
|
/// 1. Hent samlingens publishing-konfig (tema, slug, custom_domain)
|
|
/// 2. Hent artikkelens metadata.document (TipTap JSON)
|
|
/// 3. Konverter document → HTML via tiptap::document_to_html()
|
|
/// 4. Render full artikkelside med Tera-template + SEO
|
|
/// 5. Lagre HTML i CAS
|
|
/// 6. Oppdater nodens metadata.rendered (html_hash, rendered_at, renderer_version)
|
|
pub async fn render_article_to_cas(
|
|
db: &PgPool,
|
|
cas: &CasStore,
|
|
node_id: Uuid,
|
|
collection_id: Uuid,
|
|
) -> Result<serde_json::Value, String> {
|
|
// 1. Hent samlingens publishing-konfig
|
|
let collection_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((collection_title_opt, collection_metadata)) = collection_row else {
|
|
return Err(format!("Samling {collection_id} finnes ikke"));
|
|
};
|
|
|
|
let coll_traits = collection_metadata.get("traits");
|
|
|
|
let publishing_config: PublishingConfig = coll_traits
|
|
.and_then(|t| t.get("publishing"))
|
|
.cloned()
|
|
.map(|v| serde_json::from_value(v).unwrap_or_default())
|
|
.unwrap_or_default();
|
|
|
|
let has_rss = coll_traits.and_then(|t| t.get("rss")).is_some();
|
|
|
|
let slug = publishing_config.slug.as_deref().unwrap_or("unknown");
|
|
let theme = publishing_config.theme.as_deref().unwrap_or("blogg");
|
|
let config = &publishing_config.theme_config;
|
|
let collection_title = collection_title_opt.unwrap_or_else(|| slug.to_string());
|
|
|
|
// 2. Hent artikkelens data + metadata.document + edge-metadata
|
|
let article_row: Option<(Uuid, Option<String>, Option<String>, serde_json::Value, DateTime<Utc>)> = sqlx::query_as(
|
|
r#"
|
|
SELECT n.id, n.title, n.content, n.metadata, n.created_at
|
|
FROM nodes n
|
|
WHERE n.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);
|
|
|
|
// 3. Konverter metadata.document til HTML (eller bruk content som fallback)
|
|
let article_html = if let Some(doc) = metadata.get("document") {
|
|
let html = tiptap::document_to_html(doc);
|
|
if html.is_empty() {
|
|
// Fallback til content-feltet
|
|
content.unwrap_or_default()
|
|
} else {
|
|
html
|
|
}
|
|
} else {
|
|
// Ingen document — bruk content direkte
|
|
content.unwrap_or_default()
|
|
};
|
|
|
|
let article_title = title.unwrap_or_else(|| "Uten tittel".to_string());
|
|
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: short_id.clone(),
|
|
title: article_title,
|
|
content: article_html,
|
|
summary: Some(summary_text),
|
|
published_at: publish_at.to_rfc3339(),
|
|
published_at_short: publish_at.format("%e. %B %Y").to_string(),
|
|
};
|
|
|
|
// 4. Bygg SEO-data og render med Tera
|
|
let base_url = publishing_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(&tera, theme, config, &article_data, &collection_title, &base_url, &seo, has_rss)
|
|
.map_err(|e| format!("Tera render-feil: {e}"))?;
|
|
|
|
// 5. Lagre i CAS
|
|
let store_result = cas
|
|
.store(html.as_bytes())
|
|
.await
|
|
.map_err(|e| format!("CAS-lagring feilet: {e}"))?;
|
|
|
|
tracing::info!(
|
|
node_id = %node_id,
|
|
hash = %store_result.hash,
|
|
size = store_result.size,
|
|
deduplicated = store_result.already_existed,
|
|
"Artikkel rendret og lagret i CAS"
|
|
);
|
|
|
|
// 6. Oppdater nodens metadata.rendered
|
|
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(&store_result.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 = %store_result.hash,
|
|
renderer_version = RENDERER_VERSION,
|
|
"metadata.rendered oppdatert"
|
|
);
|
|
|
|
Ok(serde_json::json!({
|
|
"html_hash": store_result.hash,
|
|
"size": store_result.size,
|
|
"renderer_version": RENDERER_VERSION
|
|
}))
|
|
}
|
|
|
|
/// Render forsiden til CAS (statisk modus).
|
|
///
|
|
/// Henter hero/featured/stream med tre indekserte spørringer,
|
|
/// rendrer via Tera-template, lagrer HTML i CAS, og oppdaterer
|
|
/// samlingens metadata.rendered_index med index_hash.
|
|
pub async fn render_index_to_cas(
|
|
db: &PgPool,
|
|
cas: &CasStore,
|
|
collection_id: Uuid,
|
|
) -> Result<serde_json::Value, String> {
|
|
// Hent samlingens konfig
|
|
let collection_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((collection_title_opt, collection_metadata)) = collection_row else {
|
|
return Err(format!("Samling {collection_id} finnes ikke"));
|
|
};
|
|
|
|
let idx_traits = collection_metadata.get("traits");
|
|
|
|
let publishing_config: PublishingConfig = idx_traits
|
|
.and_then(|t| t.get("publishing"))
|
|
.cloned()
|
|
.map(|v| serde_json::from_value(v).unwrap_or_default())
|
|
.unwrap_or_default();
|
|
|
|
let has_rss = idx_traits.and_then(|t| t.get("rss")).is_some();
|
|
|
|
let slug = publishing_config.slug.as_deref().unwrap_or("unknown");
|
|
let theme = publishing_config.theme.as_deref().unwrap_or("blogg");
|
|
let config = &publishing_config.theme_config;
|
|
let collection_title = collection_title_opt.unwrap_or_else(|| slug.to_string());
|
|
let featured_max = publishing_config.featured_max.unwrap_or(4);
|
|
let stream_page_size = publishing_config.stream_page_size.unwrap_or(20);
|
|
|
|
let base_url = publishing_config
|
|
.custom_domain
|
|
.as_deref()
|
|
.map(|d| format!("https://{d}"))
|
|
.unwrap_or_else(|| format!("/pub/{slug}"));
|
|
|
|
// Hent artikler med tre indekserte spørringer
|
|
let (hero, featured, stream) =
|
|
fetch_index_articles_optimized(db, collection_id, featured_max, stream_page_size).await
|
|
.map_err(|e| format!("Feil ved henting av forsideartikler: {e}"))?;
|
|
|
|
let index_data = IndexData {
|
|
title: collection_title,
|
|
description: None,
|
|
hero,
|
|
featured,
|
|
stream,
|
|
};
|
|
|
|
// Render med Tera
|
|
let tera = build_tera();
|
|
let html = render_index(&tera, theme, config, &index_data, &base_url, has_rss)
|
|
.map_err(|e| format!("Tera render-feil (index): {e}"))?;
|
|
|
|
// Lagre i CAS
|
|
let store_result = cas
|
|
.store(html.as_bytes())
|
|
.await
|
|
.map_err(|e| format!("CAS-lagring feilet: {e}"))?;
|
|
|
|
tracing::info!(
|
|
collection_id = %collection_id,
|
|
hash = %store_result.hash,
|
|
size = store_result.size,
|
|
deduplicated = store_result.already_existed,
|
|
"Forside rendret og lagret i CAS"
|
|
);
|
|
|
|
// Oppdater samlingens metadata.rendered_index
|
|
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(&store_result.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 = %store_result.hash,
|
|
"metadata.rendered_index oppdatert"
|
|
);
|
|
|
|
Ok(serde_json::json!({
|
|
"index_hash": store_result.hash,
|
|
"size": store_result.size,
|
|
"renderer_version": RENDERER_VERSION
|
|
}))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Database-spørringer
|
|
// =============================================================================
|
|
|
|
struct CollectionRow {
|
|
id: Uuid,
|
|
title: Option<String>,
|
|
publishing_config: PublishingConfig,
|
|
has_rss: bool,
|
|
}
|
|
|
|
/// Finn samling med publishing-trait basert på slug.
|
|
async fn find_publishing_collection(
|
|
db: &PgPool,
|
|
slug: &str,
|
|
) -> Result<Option<CollectionRow>, sqlx::Error> {
|
|
let row: Option<(Uuid, Option<String>, serde_json::Value)> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, title, metadata
|
|
FROM nodes
|
|
WHERE node_kind = 'collection'
|
|
AND metadata->'traits'->'publishing'->>'slug' = $1
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(slug)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some((id, title, metadata)) = row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let traits = metadata.get("traits");
|
|
|
|
let publishing_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();
|
|
|
|
Ok(Some(CollectionRow {
|
|
id,
|
|
title,
|
|
publishing_config,
|
|
has_rss,
|
|
}))
|
|
}
|
|
|
|
/// Finn publishing-samling basert på samlings-ID.
|
|
/// Returnerer None hvis samlingen ikke har publishing-trait.
|
|
pub async fn find_publishing_collection_by_id(
|
|
db: &PgPool,
|
|
collection_id: Uuid,
|
|
) -> Result<Option<PublishingConfig>, sqlx::Error> {
|
|
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
|
r#"
|
|
SELECT metadata
|
|
FROM nodes
|
|
WHERE id = $1
|
|
AND node_kind = 'collection'
|
|
AND metadata->'traits' ? 'publishing'
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some((metadata,)) = row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let config: PublishingConfig = metadata
|
|
.get("traits")
|
|
.and_then(|t| t.get("publishing"))
|
|
.cloned()
|
|
.map(|v| serde_json::from_value(v).unwrap_or_default())
|
|
.unwrap_or_default();
|
|
|
|
Ok(Some(config))
|
|
}
|
|
|
|
/// Hent artikkeldata for en enkelt node (belongs_to samlingen).
|
|
/// Returnerer også nodens metadata (for å sjekke rendered.html_hash).
|
|
async fn fetch_article(
|
|
db: &PgPool,
|
|
collection_id: Uuid,
|
|
article_short_id: &str,
|
|
) -> Result<Option<FetchedArticle>, sqlx::Error> {
|
|
// short_id er de første 8 tegnene av UUID
|
|
let pattern = format!("{article_short_id}%");
|
|
|
|
let row: Option<(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
|
|
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.id::text LIKE $2
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(&pattern)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some((id, title, content, node_metadata, created_at, edge_meta)) = row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let publish_at = edge_meta
|
|
.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);
|
|
|
|
// Sjekk om det finnes rendret HTML i CAS
|
|
let html_hash = node_metadata
|
|
.get("rendered")
|
|
.and_then(|r| r.get("html_hash"))
|
|
.and_then(|h| h.as_str())
|
|
.map(|s| s.to_string());
|
|
|
|
// Konverter metadata.document til HTML, eller bruk content som fallback
|
|
let article_html = if let Some(doc) = node_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 summary_text = truncate(
|
|
&article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "),
|
|
200,
|
|
);
|
|
|
|
let article = ArticleData {
|
|
id: id.to_string(),
|
|
short_id: id.to_string()[..8].to_string(),
|
|
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
|
|
content: article_html,
|
|
summary: Some(summary_text),
|
|
published_at: publish_at.to_rfc3339(),
|
|
published_at_short: publish_at.format("%e. %B %Y").to_string(),
|
|
};
|
|
|
|
Ok(Some(FetchedArticle {
|
|
article,
|
|
html_hash,
|
|
edge_meta,
|
|
}))
|
|
}
|
|
|
|
struct FetchedArticle {
|
|
article: ArticleData,
|
|
html_hash: Option<String>,
|
|
#[allow(dead_code)]
|
|
edge_meta: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Hent forsideartikler med tre separate, indekserte spørringer.
|
|
///
|
|
/// Hver spørring filtrerer på slot i edge.metadata og bruker
|
|
/// GIN-indeks på edges.metadata. Mer effektivt enn å hente alt
|
|
/// og filtrere i Rust, spesielt med mange artikler.
|
|
async fn fetch_index_articles_optimized(
|
|
db: &PgPool,
|
|
collection_id: Uuid,
|
|
featured_max: i64,
|
|
stream_page_size: i64,
|
|
) -> Result<(Option<ArticleData>, Vec<ArticleData>, Vec<ArticleData>), sqlx::Error> {
|
|
// Hjelpefunksjon for å konvertere rader til ArticleData
|
|
fn row_to_article(
|
|
id: Uuid,
|
|
title: Option<String>,
|
|
content: Option<String>,
|
|
created_at: DateTime<Utc>,
|
|
edge_meta: Option<serde_json::Value>,
|
|
) -> ArticleData {
|
|
let publish_at = edge_meta
|
|
.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 summary = content.as_deref().map(|c| truncate(c, 200));
|
|
|
|
ArticleData {
|
|
id: id.to_string(),
|
|
short_id: id.to_string()[..8].to_string(),
|
|
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
|
|
content: content.unwrap_or_default(),
|
|
summary,
|
|
published_at: publish_at.to_rfc3339(),
|
|
published_at_short: publish_at.format("%e. %B %Y").to_string(),
|
|
}
|
|
}
|
|
|
|
type Row = (Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>);
|
|
|
|
// 1. Hero: slot = "hero", maks 1
|
|
let hero_row: Option<Row> = sqlx::query_as(
|
|
r#"
|
|
SELECT n.id, n.title, n.content, n.created_at, e.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 e.metadata->>'slot' = 'hero'
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let hero = hero_row.map(|(id, title, content, created_at, edge_meta)| {
|
|
row_to_article(id, title, content, created_at, edge_meta)
|
|
});
|
|
|
|
// 2. Featured: slot = "featured", sortert på slot_order
|
|
let featured_rows: Vec<Row> = sqlx::query_as(
|
|
r#"
|
|
SELECT n.id, n.title, n.content, n.created_at, e.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 e.metadata->>'slot' = 'featured'
|
|
ORDER BY (e.metadata->>'slot_order')::int ASC NULLS LAST
|
|
LIMIT $2
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(featured_max)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
let featured: Vec<ArticleData> = featured_rows
|
|
.into_iter()
|
|
.map(|(id, title, content, created_at, edge_meta)| {
|
|
row_to_article(id, title, content, created_at, edge_meta)
|
|
})
|
|
.collect();
|
|
|
|
// 3. Strøm: slot IS NULL (eller mangler), sortert på publish_at
|
|
let stream_rows: Vec<Row> = sqlx::query_as(
|
|
r#"
|
|
SELECT n.id, n.title, n.content, n.created_at, e.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 (e.metadata->>'slot' IS NULL OR e.metadata->>'slot' = '')
|
|
ORDER BY COALESCE(
|
|
(e.metadata->>'publish_at')::timestamptz,
|
|
n.created_at
|
|
) DESC
|
|
LIMIT $2
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(stream_page_size)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
let stream: Vec<ArticleData> = stream_rows
|
|
.into_iter()
|
|
.map(|(id, title, content, created_at, edge_meta)| {
|
|
row_to_article(id, title, content, created_at, edge_meta)
|
|
})
|
|
.collect();
|
|
|
|
Ok((hero, featured, stream))
|
|
}
|
|
|
|
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]),
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// HTTP-handlers
|
|
// =============================================================================
|
|
|
|
/// GET /pub/{slug} — forside for en publikasjon.
|
|
///
|
|
/// Støtter to moduser styrt av `index_mode` i trait-konfig:
|
|
/// - **static**: Serverer pre-rendret HTML fra CAS (immutable cache).
|
|
/// Forsiden rendres til CAS via `render_index`-jobb ved publisering.
|
|
/// - **dynamic** (default): Rendrer on-the-fly med in-memory cache.
|
|
/// TTL styres av `index_cache_ttl` (default 300s).
|
|
pub async fn serve_index(
|
|
State(state): State<AppState>,
|
|
Path(slug): Path<String>,
|
|
) -> Result<Response, StatusCode> {
|
|
let collection = find_publishing_collection(&state.db, &slug)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?
|
|
.ok_or(StatusCode::NOT_FOUND)?;
|
|
|
|
let index_mode = collection.publishing_config.index_mode.as_deref().unwrap_or("dynamic");
|
|
let cache_ttl = collection.publishing_config.index_cache_ttl.unwrap_or(300);
|
|
|
|
// --- Statisk modus: server fra CAS ---
|
|
if index_mode == "static" {
|
|
// Sjekk metadata.rendered_index.index_hash
|
|
let hash_row: Option<(serde_json::Value,)> = sqlx::query_as(
|
|
"SELECT metadata FROM nodes WHERE id = $1",
|
|
)
|
|
.bind(collection.id)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
if let Some((metadata,)) = hash_row {
|
|
if let Some(index_hash) = metadata
|
|
.get("rendered_index")
|
|
.and_then(|r| r.get("index_hash"))
|
|
.and_then(|h| h.as_str())
|
|
{
|
|
let cas_path = state.cas.path_for(index_hash);
|
|
if cas_path.exists() {
|
|
let html_bytes = tokio::fs::read(&cas_path).await.map_err(|e| {
|
|
tracing::error!(hash = %index_hash, error = %e, "Kunne ikke lese CAS-fil for index");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
return Ok(Response::builder()
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
.header(
|
|
header::CACHE_CONTROL,
|
|
"public, max-age=31536000, immutable",
|
|
)
|
|
.body(html_bytes.into())
|
|
.unwrap());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallthrough: ingen CAS-versjon — render on-the-fly som fallback
|
|
tracing::warn!(slug = %slug, "Statisk index mangler i CAS, faller tilbake til dynamisk rendering");
|
|
}
|
|
|
|
// --- Dynamisk modus: in-memory cache med TTL ---
|
|
{
|
|
let cache = state.index_cache.read().await;
|
|
if let Some(cached) = cache.get(&collection.id) {
|
|
if cached.expires_at > Utc::now() {
|
|
let max_age = (cached.expires_at - Utc::now()).num_seconds().max(0);
|
|
return Ok(Response::builder()
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
.header(
|
|
header::CACHE_CONTROL,
|
|
format!("public, max-age={max_age}"),
|
|
)
|
|
.body(cached.html.clone().into())
|
|
.unwrap());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cache miss eller utløpt — render og cache
|
|
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
|
|
let config = collection.publishing_config.theme_config.clone();
|
|
let featured_max = collection.publishing_config.featured_max.unwrap_or(4);
|
|
let stream_page_size = collection.publishing_config.stream_page_size.unwrap_or(20);
|
|
|
|
let (hero, featured, stream) = fetch_index_articles_optimized(
|
|
&state.db,
|
|
collection.id,
|
|
featured_max,
|
|
stream_page_size,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(slug = %slug, error = %e, "Feil ved henting av forsideartikler");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
|
|
let base_url = collection
|
|
.publishing_config
|
|
.custom_domain
|
|
.as_deref()
|
|
.map(|d| format!("https://{d}"))
|
|
.unwrap_or_else(|| format!("/pub/{slug}"));
|
|
|
|
let index_data = IndexData {
|
|
title: collection_title,
|
|
description: None,
|
|
hero,
|
|
featured,
|
|
stream,
|
|
};
|
|
|
|
let tera = build_tera();
|
|
let html = render_index(&tera, theme, &config, &index_data, &base_url, collection.has_rss).map_err(|e| {
|
|
tracing::error!(slug = %slug, theme = %theme, error = %e, "Tera render-feil (index)");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
// Legg i cache
|
|
let expires_at = Utc::now() + chrono::Duration::seconds(cache_ttl as i64);
|
|
{
|
|
let mut cache = state.index_cache.write().await;
|
|
cache.insert(collection.id, CachedIndex {
|
|
html: html.clone(),
|
|
expires_at,
|
|
});
|
|
}
|
|
|
|
Ok(Response::builder()
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
.header(
|
|
header::CACHE_CONTROL,
|
|
format!("public, max-age={cache_ttl}"),
|
|
)
|
|
.body(html.into())
|
|
.unwrap())
|
|
}
|
|
|
|
/// GET /pub/{slug}/{article_id} — enkeltartikkel.
|
|
///
|
|
/// Serverer fra CAS hvis artikkelen er pre-rendret (metadata.rendered.html_hash).
|
|
/// Faller tilbake til on-the-fly rendering hvis ikke.
|
|
pub async fn serve_article(
|
|
State(state): State<AppState>,
|
|
Path((slug, article_id)): Path<(String, String)>,
|
|
) -> Result<Response, StatusCode> {
|
|
let collection = find_publishing_collection(&state.db, &slug)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?
|
|
.ok_or(StatusCode::NOT_FOUND)?;
|
|
|
|
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
|
|
let config = &collection.publishing_config.theme_config;
|
|
|
|
let fetched = fetch_article(&state.db, collection.id, &article_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(slug = %slug, article = %article_id, error = %e, "Feil ved henting av artikkel");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?
|
|
.ok_or(StatusCode::NOT_FOUND)?;
|
|
|
|
// Sjekk om pre-rendret HTML finnes i CAS
|
|
if let Some(ref hash) = fetched.html_hash {
|
|
let cas_path = state.cas.path_for(hash);
|
|
if cas_path.exists() {
|
|
let html_bytes = tokio::fs::read(&cas_path).await.map_err(|e| {
|
|
tracing::error!(hash = %hash, error = %e, "Kunne ikke lese CAS-fil");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
return Ok(Response::builder()
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
.header(
|
|
header::CACHE_CONTROL,
|
|
"public, max-age=31536000, immutable",
|
|
)
|
|
.body(html_bytes.into())
|
|
.unwrap());
|
|
}
|
|
}
|
|
|
|
// Fallback: render on-the-fly
|
|
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
|
|
let base_url = collection
|
|
.publishing_config
|
|
.custom_domain
|
|
.as_deref()
|
|
.map(|d| format!("https://{d}"))
|
|
.unwrap_or_else(|| format!("/pub/{slug}"));
|
|
|
|
let canonical_url = format!("{base_url}/{}", fetched.article.short_id);
|
|
let seo = build_seo_data(&fetched.article, &collection_title, &canonical_url);
|
|
|
|
let tera = build_tera();
|
|
let html = render_article(&tera, theme, config, &fetched.article, &collection_title, &base_url, &seo, collection.has_rss)
|
|
.map_err(|e| {
|
|
tracing::error!(slug = %slug, article = %article_id, theme = %theme, error = %e, "Tera render-feil (artikkel)");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Response::builder()
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
.header(header::CACHE_CONTROL, "public, max-age=300")
|
|
.body(html.into())
|
|
.unwrap())
|
|
}
|
|
|
|
/// GET /pub/{slug}/preview/{theme} — forhåndsvisning av tema med testdata.
|
|
/// Nyttig for å se hvordan et tema ser ut uten reelle data.
|
|
pub async fn preview_theme(
|
|
Path((slug, theme)): Path<(String, String)>,
|
|
) -> Result<Response, StatusCode> {
|
|
let valid_themes = ["avis", "magasin", "blogg", "tidsskrift"];
|
|
if !valid_themes.contains(&theme.as_str()) {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
let config = ThemeConfig::default();
|
|
|
|
let sample_articles: Vec<ArticleData> = (1..=6)
|
|
.map(|i| ArticleData {
|
|
id: format!("00000000-0000-0000-0000-00000000000{i}"),
|
|
short_id: format!("0000000{i}"),
|
|
title: format!("Eksempelartikkel {i}"),
|
|
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
|
|
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
|
|
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."
|
|
.to_string(),
|
|
summary: Some("Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string()),
|
|
published_at: "2026-03-18T12:00:00Z".to_string(),
|
|
published_at_short: "18. mars 2026".to_string(),
|
|
})
|
|
.collect();
|
|
|
|
let index_data = IndexData {
|
|
title: format!("Forhåndsvisning — {theme}"),
|
|
description: Some("Eksempeldata for temavisning".to_string()),
|
|
hero: Some(sample_articles[0].clone()),
|
|
featured: sample_articles[1..4].to_vec(),
|
|
stream: sample_articles[4..].to_vec(),
|
|
};
|
|
|
|
let base_url = format!("/pub/{slug}");
|
|
|
|
let tera = build_tera();
|
|
let html = render_index(&tera, &theme, &config, &index_data, &base_url, false).map_err(|e| {
|
|
tracing::error!(theme = %theme, error = %e, "Tera render-feil (preview)");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Response::builder()
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
.header(header::CACHE_CONTROL, "no-cache")
|
|
.body(html.into())
|
|
.unwrap())
|
|
}
|
|
|
|
// =============================================================================
|
|
// Planlagt publisering — periodisk scheduler
|
|
// =============================================================================
|
|
|
|
/// Rad fra spørring for planlagte artikler som er klare for publisering.
|
|
#[derive(sqlx::FromRow, Debug)]
|
|
struct ScheduledArticle {
|
|
node_id: Uuid,
|
|
collection_id: Uuid,
|
|
}
|
|
|
|
/// Finn belongs_to-edges med publish_at i fortiden der artikkelen
|
|
/// ikke er rendret ennå. Returnerer (node_id, collection_id)-par.
|
|
async fn find_due_articles(db: &PgPool) -> Result<Vec<ScheduledArticle>, sqlx::Error> {
|
|
// En artikkel er "due" når:
|
|
// 1. belongs_to-edge har publish_at <= now()
|
|
// 2. Noden mangler metadata.rendered.html_hash (ikke rendret)
|
|
//
|
|
// Vi sjekker også at det ikke allerede finnes en pending/running
|
|
// render_article-jobb for denne noden, for å unngå duplikater.
|
|
sqlx::query_as::<_, ScheduledArticle>(
|
|
r#"
|
|
SELECT
|
|
e.source_id AS node_id,
|
|
e.target_id AS collection_id
|
|
FROM edges e
|
|
JOIN nodes n ON n.id = e.source_id
|
|
WHERE e.edge_type = 'belongs_to'
|
|
AND (e.metadata->>'publish_at')::timestamptz <= now()
|
|
AND (
|
|
n.metadata->'rendered'->>'html_hash' IS NULL
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM job_queue jq
|
|
WHERE jq.job_type = 'render_article'
|
|
AND jq.status IN ('pending', 'running', 'retry')
|
|
AND jq.payload->>'node_id' = n.id::text
|
|
)
|
|
"#,
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
}
|
|
|
|
/// Kjør én runde med planlagt publisering.
|
|
/// Returnerer antall artikler som ble lagt i render-kø.
|
|
async fn run_publish_scheduler(db: &PgPool) -> Result<usize, String> {
|
|
let due = find_due_articles(db)
|
|
.await
|
|
.map_err(|e| format!("Spørring for planlagte artikler feilet: {e}"))?;
|
|
|
|
if due.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
tracing::info!(count = due.len(), "Fant planlagte artikler klare for publisering");
|
|
|
|
// Samle unike collection_ids for index-oppdatering etterpå
|
|
let mut collections_to_reindex: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
|
|
|
for article in &due {
|
|
let payload = serde_json::json!({
|
|
"node_id": article.node_id.to_string(),
|
|
"collection_id": article.collection_id.to_string(),
|
|
});
|
|
|
|
match jobs::enqueue(db, "render_article", payload, Some(article.collection_id), 5).await {
|
|
Ok(job_id) => {
|
|
tracing::info!(
|
|
job_id = %job_id,
|
|
node_id = %article.node_id,
|
|
collection_id = %article.collection_id,
|
|
"Render-jobb opprettet for planlagt artikkel"
|
|
);
|
|
collections_to_reindex.insert(article.collection_id);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
node_id = %article.node_id,
|
|
error = %e,
|
|
"Kunne ikke opprette render-jobb for planlagt artikkel"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legg inn render_index-jobb for hver berørt samling (lavere prioritet)
|
|
for collection_id in &collections_to_reindex {
|
|
let payload = serde_json::json!({
|
|
"collection_id": collection_id.to_string(),
|
|
});
|
|
|
|
if let Err(e) = jobs::enqueue(db, "render_index", payload, Some(*collection_id), 3).await {
|
|
tracing::error!(
|
|
collection_id = %collection_id,
|
|
error = %e,
|
|
"Kunne ikke opprette render_index-jobb"
|
|
);
|
|
}
|
|
}
|
|
|
|
let count = due.len();
|
|
tracing::info!(
|
|
articles = count,
|
|
collections = collections_to_reindex.len(),
|
|
"Planlagt publisering: jobber lagt i kø"
|
|
);
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
/// Start periodisk scheduler for planlagt publisering.
|
|
/// Sjekker hvert 60. sekund for artikler med publish_at i fortiden.
|
|
pub fn start_publish_scheduler(db: PgPool) {
|
|
tokio::spawn(async move {
|
|
// Vent 30 sekunder etter oppstart før første sjekk
|
|
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
|
tracing::info!("Planlagt publisering-scheduler startet (intervall: 60s)");
|
|
|
|
loop {
|
|
match run_publish_scheduler(&db).await {
|
|
Ok(count) => {
|
|
if count > 0 {
|
|
tracing::info!(
|
|
articles = count,
|
|
"Planlagt publisering: {} artikler lagt i render-kø",
|
|
count,
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "Planlagt publisering-scheduler feilet");
|
|
}
|
|
}
|
|
|
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Bulk re-rendering ved temaendring (oppgave 14.14)
|
|
// =============================================================================
|
|
|
|
/// Paginert batch-jobb: finn artikler som trenger re-rendering og enqueue
|
|
/// render_article-jobber i grupper på 100. Artikler serveres med gammelt
|
|
/// tema til de er re-rendret — renderer_version i metadata identifiserer
|
|
/// hvilke som gjenstår.
|
|
///
|
|
/// Kalles når theme eller theme_config endres på en samling.
|
|
pub async fn trigger_bulk_rerender(
|
|
db: &PgPool,
|
|
collection_id: Uuid,
|
|
) -> Result<usize, String> {
|
|
let batch_size: i64 = 100;
|
|
let mut total_enqueued: usize = 0;
|
|
|
|
loop {
|
|
// Finn neste batch artikler som trenger re-rendering.
|
|
// Filtrerer ut artikler som allerede har pending/running render-jobb.
|
|
let article_ids: Vec<(Uuid,)> = sqlx::query_as(
|
|
r#"
|
|
SELECT e.source_id
|
|
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.metadata->'rendered'->>'renderer_version' IS NULL
|
|
OR (n.metadata->'rendered'->>'renderer_version')::bigint < $2
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM job_queue jq
|
|
WHERE jq.job_type = 'render_article'
|
|
AND jq.status IN ('pending', 'running', 'retry')
|
|
AND jq.payload->>'node_id' = n.id::text
|
|
)
|
|
LIMIT $3
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(RENDERER_VERSION)
|
|
.bind(batch_size)
|
|
.fetch_all(db)
|
|
.await
|
|
.map_err(|e| format!("Feil ved henting av artikler for bulk rerender: {e}"))?;
|
|
|
|
if article_ids.is_empty() {
|
|
break;
|
|
}
|
|
|
|
let batch_count = article_ids.len();
|
|
|
|
for (article_id,) in &article_ids {
|
|
let payload = serde_json::json!({
|
|
"node_id": article_id.to_string(),
|
|
"collection_id": collection_id.to_string(),
|
|
});
|
|
if let Err(e) = jobs::enqueue(db, "render_article", payload, Some(collection_id), 3).await {
|
|
tracing::error!(
|
|
article_id = %article_id,
|
|
collection_id = %collection_id,
|
|
error = %e,
|
|
"Kunne ikke enqueue render_article ved temaendring"
|
|
);
|
|
}
|
|
}
|
|
|
|
total_enqueued += batch_count;
|
|
|
|
tracing::info!(
|
|
collection_id = %collection_id,
|
|
batch = batch_count,
|
|
total = total_enqueued,
|
|
"Bulk rerender batch enqueued"
|
|
);
|
|
|
|
// Hvis batchen var mindre enn batch_size, er vi ferdige
|
|
if (batch_count as i64) < batch_size {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Enqueue render av forsiden til slutt (lavere prioritet)
|
|
let index_payload = serde_json::json!({
|
|
"collection_id": collection_id.to_string(),
|
|
});
|
|
if let Err(e) = jobs::enqueue(db, "render_index", index_payload, Some(collection_id), 4).await {
|
|
tracing::error!(
|
|
collection_id = %collection_id,
|
|
error = %e,
|
|
"Kunne ikke enqueue render_index ved temaendring"
|
|
);
|
|
}
|
|
|
|
tracing::info!(
|
|
collection_id = %collection_id,
|
|
total_articles = total_enqueued,
|
|
"Bulk re-rendering enqueued ved temaendring"
|
|
);
|
|
|
|
Ok(total_enqueued)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tester
|
|
// =============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn default_seo() -> SeoData {
|
|
SeoData {
|
|
og_title: "Test".to_string(),
|
|
description: "Beskrivelse".to_string(),
|
|
canonical_url: "https://example.com/test".to_string(),
|
|
og_image: None,
|
|
json_ld: "{}".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn css_variables_use_defaults() {
|
|
let config = ThemeConfig::default();
|
|
let css = build_css_variables("avis", &config);
|
|
assert!(css.contains("--color-primary: #1a1a2e"));
|
|
assert!(css.contains("--font-heading: 'Georgia'"));
|
|
assert!(css.contains("--layout-max-width: 1200px"));
|
|
}
|
|
|
|
#[test]
|
|
fn 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 skal fortsatt bruke blogg-defaults
|
|
assert!(css.contains("--color-accent: #3498db"));
|
|
}
|
|
|
|
#[test]
|
|
fn tera_builds_successfully() {
|
|
let tera = build_tera();
|
|
// Alle 8 tema-templates + base skal finnes
|
|
let templates: Vec<&str> = tera.get_template_names().collect();
|
|
assert!(templates.contains(&"base.html"));
|
|
assert!(templates.contains(&"avis/article.html"));
|
|
assert!(templates.contains(&"avis/index.html"));
|
|
assert!(templates.contains(&"magasin/article.html"));
|
|
assert!(templates.contains(&"magasin/index.html"));
|
|
assert!(templates.contains(&"blogg/article.html"));
|
|
assert!(templates.contains(&"blogg/index.html"));
|
|
assert!(templates.contains(&"tidsskrift/article.html"));
|
|
assert!(templates.contains(&"tidsskrift/index.html"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_article_all_themes() {
|
|
let tera = build_tera();
|
|
let config = ThemeConfig::default();
|
|
let article = ArticleData {
|
|
id: "test-id".to_string(),
|
|
short_id: "test-sho".to_string(),
|
|
title: "Testittel".to_string(),
|
|
content: "<p>Testinnhold</p>".to_string(),
|
|
summary: Some("Kort oppsummering".to_string()),
|
|
published_at: "2026-03-18T12:00:00Z".to_string(),
|
|
published_at_short: "18. mars 2026".to_string(),
|
|
};
|
|
|
|
let seo = default_seo();
|
|
|
|
for theme in &["avis", "magasin", "blogg", "tidsskrift"] {
|
|
let html = render_article(&tera, theme, &config, &article, "Testsamling", "/pub/test", &seo, false)
|
|
.unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}"));
|
|
assert!(html.contains("Testittel"), "Tittel mangler i {theme}");
|
|
assert!(html.contains("Testinnhold"), "Innhold mangler i {theme}");
|
|
assert!(html.contains("--color-primary"), "CSS-variabler mangler i {theme}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn render_article_includes_seo() {
|
|
let tera = build_tera();
|
|
let config = ThemeConfig::default();
|
|
let article = ArticleData {
|
|
id: "seo-test".to_string(),
|
|
short_id: "seo-test".to_string(),
|
|
title: "SEO-tittel".to_string(),
|
|
content: "<p>Innhold</p>".to_string(),
|
|
summary: Some("SEO-beskrivelse her".to_string()),
|
|
published_at: "2026-03-18T12:00:00Z".to_string(),
|
|
published_at_short: "18. mars 2026".to_string(),
|
|
};
|
|
|
|
let seo = SeoData {
|
|
og_title: "SEO-tittel".to_string(),
|
|
description: "SEO-beskrivelse her".to_string(),
|
|
canonical_url: "https://example.com/seo-test".to_string(),
|
|
og_image: None,
|
|
json_ld: r#"{"@type":"Article"}"#.to_string(),
|
|
};
|
|
|
|
let html = render_article(&tera, "blogg", &config, &article, "Testpub", "/pub/test", &seo, false)
|
|
.expect("Render feilet");
|
|
|
|
assert!(html.contains("og:title"), "OG-tittel mangler");
|
|
assert!(html.contains("og:description"), "OG-beskrivelse mangler");
|
|
assert!(html.contains("canonical"), "Canonical URL mangler");
|
|
assert!(html.contains("application/ld+json"), "JSON-LD mangler");
|
|
assert!(html.contains("SEO-beskrivelse her"), "Beskrivelse mangler");
|
|
}
|
|
|
|
#[test]
|
|
fn render_index_all_themes() {
|
|
let tera = build_tera();
|
|
let config = ThemeConfig::default();
|
|
let index = IndexData {
|
|
title: "Testforside".to_string(),
|
|
description: None,
|
|
hero: None,
|
|
featured: vec![],
|
|
stream: vec![ArticleData {
|
|
id: "s1".to_string(),
|
|
short_id: "s1000000".to_string(),
|
|
title: "Strøm-artikkel".to_string(),
|
|
content: "Innhold".to_string(),
|
|
summary: Some("Sammendrag".to_string()),
|
|
published_at: "2026-03-18T12:00:00Z".to_string(),
|
|
published_at_short: "18. mars 2026".to_string(),
|
|
}],
|
|
};
|
|
|
|
for theme in &["avis", "magasin", "blogg", "tidsskrift"] {
|
|
let html = render_index(&tera, theme, &config, &index, "/pub/test", false)
|
|
.unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}"));
|
|
assert!(html.contains("Testforside"), "Tittel mangler i {theme}");
|
|
assert!(html.contains("Strøm-artikkel"), "Strøm-artikkel mangler i {theme}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_theme_falls_back_to_blogg() {
|
|
let config = ThemeConfig::default();
|
|
let css = build_css_variables("nonexistent", &config);
|
|
let css_blogg = build_css_variables("blogg", &config);
|
|
assert_eq!(css, css_blogg);
|
|
}
|
|
|
|
#[test]
|
|
fn json_ld_contains_required_fields() {
|
|
let article = ArticleData {
|
|
id: "test".to_string(),
|
|
short_id: "test1234".to_string(),
|
|
title: "Test-artikkel".to_string(),
|
|
content: "Innhold".to_string(),
|
|
summary: Some("Oppsummering".to_string()),
|
|
published_at: "2026-03-18T12:00:00Z".to_string(),
|
|
published_at_short: "18. mars 2026".to_string(),
|
|
};
|
|
|
|
let ld = build_json_ld(&article, "Testpub", "https://example.com/test");
|
|
assert!(ld.contains("\"@type\":\"Article\""));
|
|
assert!(ld.contains("\"headline\":\"Test-artikkel\""));
|
|
assert!(ld.contains("\"datePublished\""));
|
|
assert!(ld.contains("\"publisher\""));
|
|
}
|
|
}
|