HTML-rendering av enkeltartikler til CAS med SEO-metadata (oppgave 14.2)

Implementerer rendering-pipeline: metadata.document (TipTap JSON) → HTML
via Tera-templates → CAS-lagring → metadata.rendered oppdateres.

Nye moduler:
- tiptap.rs: Konverterer TipTap/ProseMirror JSON til HTML. Støtter
  paragraph, heading, blockquote, lister, code_block, image, hr,
  og marks (bold, italic, strike, code, link, underline).
  XSS-sikker med HTML-escaping.

- render_article jobb i jobbkøen: Henter node + samling, konverterer
  document → HTML, rendrer med Tera + tema, lagrer i CAS, oppdaterer
  nodens metadata.rendered med html_hash og renderer_version.

Endringer:
- publishing.rs: SeoData-struct med OG-tags, canonical URL, JSON-LD.
  render_article_to_cas() for full pipeline. serve_article() serverer
  fra CAS (immutable cache) hvis pre-rendret, fallback til on-the-fly.
  RENDERER_VERSION=1 for fremtidig bulk re-rendering.

- intentions.rs: Trigger render_article-jobb automatisk når belongs_to
  edge opprettes til samling med publishing-trait.

- Alle 4 artikkel-templates: SEO-block med meta description, OG-tags
  (type, title, description, url, site_name, image, published_time),
  canonical URL, RSS-link, og JSON-LD structured data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 00:52:58 +00:00
parent 1cb0d43f21
commit e050612dec
12 changed files with 965 additions and 16 deletions

View file

@ -654,9 +654,9 @@ Noden peker på rendret resultat via metadata:
"metadata": {
"document": { /* TipTap/ProseMirror JSON */ },
"rendered": {
"html_hash": "cas://sha256-abc123",
"html_hash": "a1b2c3d4e5f6...", // SHA-256 hex-digest, peker til CAS
"rendered_at": "2026-03-17T14:30:00Z",
"renderer_version": 2
"renderer_version": 1
}
}
}

View file

@ -1704,6 +1704,11 @@ fn spawn_pg_insert_edge(
match result {
Ok(_) => {
tracing::info!(edge_id = %edge_id, "Edge persistert til PostgreSQL");
// Trigger artikkelrendering ved belongs_to til publiseringssamling
if edge_type == "belongs_to" {
trigger_render_if_publishing(&db, source_id, target_id).await;
}
}
Err(e) => {
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL");
@ -1713,6 +1718,48 @@ fn spawn_pg_insert_edge(
});
}
/// Sjekker om target er en samling med publishing-trait, og legger i så fall
/// en `render_article`-jobb i køen for å rendere artikkelens HTML til CAS.
async fn trigger_render_if_publishing(db: &PgPool, source_id: Uuid, target_id: Uuid) {
match crate::publishing::find_publishing_collection_by_id(db, target_id).await {
Ok(Some(_config)) => {
let payload = serde_json::json!({
"node_id": source_id.to_string(),
"collection_id": target_id.to_string(),
});
match crate::jobs::enqueue(db, "render_article", payload, Some(target_id), 5).await {
Ok(job_id) => {
tracing::info!(
job_id = %job_id,
node_id = %source_id,
collection_id = %target_id,
"render_article-jobb lagt i kø"
);
}
Err(e) => {
tracing::error!(
node_id = %source_id,
collection_id = %target_id,
error = %e,
"Kunne ikke legge render_article-jobb i kø"
);
}
}
}
Ok(None) => {
// Target er ikke en publiseringssamling — ingen rendering nødvendig
}
Err(e) => {
tracing::error!(
target_id = %target_id,
error = %e,
"Feil ved sjekk av publiseringssamling for rendering-trigger"
);
}
}
}
/// Synkroniserer node_access-rader for et subject fra PG til STDB.
/// Kalles etter recompute_access for å holde STDB i synk.
async fn sync_node_access_to_stdb(db: &PgPool, stdb: &crate::stdb::StdbClient, subject_id: Uuid) {

View file

@ -12,6 +12,7 @@ use crate::agent;
use crate::ai_edges;
use crate::audio;
use crate::cas::CasStore;
use crate::publishing;
use crate::stdb::StdbClient;
use crate::summarize;
use crate::transcribe;
@ -171,10 +172,40 @@ async fn dispatch(
"audio_process" => {
audio::handle_audio_process_job(job, db, stdb, cas).await
}
"render_article" => {
handle_render_article(job, db, cas).await
}
other => Err(format!("Ukjent jobbtype: {other}")),
}
}
/// Handler for `render_article`-jobb.
///
/// Payload: `{ "node_id": "...", "collection_id": "..." }`
/// Rendrer artikkelens metadata.document til HTML via Tera, lagrer i CAS,
/// oppdaterer nodens metadata.rendered.
async fn handle_render_article(
job: &JobRow,
db: &PgPool,
cas: &CasStore,
) -> Result<serde_json::Value, String> {
let node_id: Uuid = job
.payload
.get("node_id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.ok_or("Mangler node_id i payload")?;
let collection_id: Uuid = job
.payload
.get("collection_id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.ok_or("Mangler collection_id i payload")?;
publishing::render_article_to_cas(db, cas, node_id, collection_id).await
}
/// Starter worker-loopen som poller job_queue.
/// Kjører som en bakgrunnsoppgave i tokio.
pub fn start_worker(db: PgPool, stdb: StdbClient, cas: CasStore) {

View file

@ -13,6 +13,7 @@ mod rss;
mod serving;
mod stdb;
pub mod summarize;
pub mod tiptap;
pub mod transcribe;
pub mod tts;
mod warmup;

View file

@ -4,7 +4,11 @@
//! Hvert tema har artikkelmal + forside-mal.
//! CSS-variabler for theme_config-overstyring.
//!
//! Ref: docs/concepts/publisering.md § "Temaer"
//! 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 axum::{
extract::{Path, State},
@ -17,8 +21,14 @@ use sqlx::PgPool;
use tera::{Context, Tera};
use uuid::Uuid;
use crate::cas::CasStore;
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 = 1;
// =============================================================================
// Tema-konfigurasjon fra publishing-trait
// =============================================================================
@ -66,6 +76,63 @@ 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
// =============================================================================
@ -221,7 +288,7 @@ pub struct IndexData {
// Render-funksjoner
// =============================================================================
/// Render en artikkel med gitt tema.
/// Render en artikkel med gitt tema og SEO-metadata.
pub fn render_article(
tera: &Tera,
theme: &str,
@ -229,6 +296,7 @@ pub fn render_article(
article: &ArticleData,
collection_title: &str,
base_url: &str,
seo: &SeoData,
) -> Result<String, tera::Error> {
let css_vars = build_css_variables(theme, config);
let template_name = format!("{theme}/article.html");
@ -240,6 +308,7 @@ pub fn render_article(
ctx.insert("collection_title", collection_title);
ctx.insert("base_url", base_url);
ctx.insert("logo_hash", &config.logo_hash);
ctx.insert("seo", seo);
tera.render(&template_name, &ctx)
}
@ -265,6 +334,199 @@ pub fn render_index(
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 publishing_config: PublishingConfig = collection_metadata
.get("traits")
.and_then(|t| t.get("publishing"))
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
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)
.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
}))
}
// =============================================================================
// Database-spørringer
// =============================================================================
@ -311,18 +573,52 @@ async fn find_publishing_collection(
}))
}
/// 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<(ArticleData, Option<serde_json::Value>)>, sqlx::Error> {
) -> 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>, DateTime<Utc>, Option<serde_json::Value>)> = sqlx::query_as(
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.created_at, e.metadata
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
@ -336,7 +632,7 @@ async fn fetch_article(
.fetch_optional(db)
.await?;
let Some((id, title, content, created_at, edge_meta)) = row else {
let Some((id, title, content, node_metadata, created_at, edge_meta)) = row else {
return Ok(None);
};
@ -347,17 +643,52 @@ async fn fetch_article(
.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: content.unwrap_or_default(),
summary: None,
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((article, edge_meta)))
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 artikler for forsiden, sortert i slots.
@ -505,6 +836,9 @@ pub async fn serve_index(
}
/// 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)>,
@ -520,7 +854,7 @@ pub async fn serve_article(
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &collection.publishing_config.theme_config;
let (article, _edge_meta) = fetch_article(&state.db, collection.id, &article_id)
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");
@ -528,6 +862,27 @@ pub async fn serve_article(
})?
.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
@ -536,8 +891,11 @@ pub async fn serve_article(
.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, &article, &collection_title, &base_url)
let html = render_article(&tera, theme, config, &fetched.article, &collection_title, &base_url, &seo)
.map_err(|e| {
tracing::error!(slug = %slug, article = %article_id, theme = %theme, error = %e, "Tera render-feil (artikkel)");
StatusCode::INTERNAL_SERVER_ERROR
@ -608,6 +966,16 @@ pub async fn preview_theme(
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();
@ -662,8 +1030,10 @@ mod tests {
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")
let html = render_article(&tera, theme, &config, &article, "Testsamling", "/pub/test", &seo)
.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}");
@ -671,6 +1041,38 @@ mod tests {
}
}
#[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)
.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();
@ -706,4 +1108,23 @@ mod tests {
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\""));
}
}

View file

@ -2,6 +2,20 @@
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.article {
max-width: var(--layout-max-width);

View file

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ collection_title | default(value="Synops") }}{% endblock %}</title>
{% block seo %}{% endblock %}
<style>
{{ css_variables | safe }}

View file

@ -2,6 +2,20 @@
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.blog-article {
max-width: var(--layout-max-width);

View file

@ -2,6 +2,20 @@
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.mag-article {
max-width: var(--layout-max-width);

View file

@ -2,6 +2,20 @@
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.journal-article {
max-width: var(--layout-max-width);

380
maskinrommet/src/tiptap.rs Normal file
View file

@ -0,0 +1,380 @@
//! TipTap/ProseMirror JSON → HTML-konvertering.
//!
//! Konverterer `metadata.document` (TipTap JSON) til HTML-streng.
//! Støtter vanlige nodetyper: paragraph, heading, blockquote, bullet_list,
//! ordered_list, list_item, code_block, horizontal_rule, image, hard_break.
//! Støtter marks: bold, italic, strike, code, link, underline.
use serde_json::Value;
/// Konverter et TipTap/ProseMirror-dokument (JSON) til HTML.
/// Returnerer tom streng hvis dokumentet er ugyldig.
pub fn document_to_html(doc: &Value) -> String {
let Some(content) = doc.get("content").and_then(|c| c.as_array()) else {
return String::new();
};
let mut html = String::new();
for node in content {
render_node(node, &mut html);
}
html
}
fn render_node(node: &Value, out: &mut String) {
let node_type = node.get("type").and_then(|t| t.as_str()).unwrap_or("");
match node_type {
"paragraph" => {
out.push_str("<p>");
render_inline_content(node, out);
out.push_str("</p>\n");
}
"heading" => {
let level = node
.get("attrs")
.and_then(|a| a.get("level"))
.and_then(|l| l.as_u64())
.unwrap_or(2)
.min(6);
out.push_str(&format!("<h{level}>"));
render_inline_content(node, out);
out.push_str(&format!("</h{level}>\n"));
}
"blockquote" => {
out.push_str("<blockquote>\n");
render_children(node, out);
out.push_str("</blockquote>\n");
}
"bulletList" | "bullet_list" => {
out.push_str("<ul>\n");
render_children(node, out);
out.push_str("</ul>\n");
}
"orderedList" | "ordered_list" => {
let start = node
.get("attrs")
.and_then(|a| a.get("start"))
.and_then(|s| s.as_u64())
.unwrap_or(1);
if start == 1 {
out.push_str("<ol>\n");
} else {
out.push_str(&format!("<ol start=\"{start}\">\n"));
}
render_children(node, out);
out.push_str("</ol>\n");
}
"listItem" | "list_item" => {
out.push_str("<li>");
render_children(node, out);
out.push_str("</li>\n");
}
"codeBlock" | "code_block" => {
let lang = node
.get("attrs")
.and_then(|a| a.get("language"))
.and_then(|l| l.as_str())
.unwrap_or("");
if lang.is_empty() {
out.push_str("<pre><code>");
} else {
out.push_str(&format!("<pre><code class=\"language-{}\">", escape_html(lang)));
}
render_inline_content(node, out);
out.push_str("</code></pre>\n");
}
"horizontalRule" | "horizontal_rule" => {
out.push_str("<hr>\n");
}
"image" => {
let attrs = node.get("attrs");
let src = attrs
.and_then(|a| a.get("src"))
.and_then(|s| s.as_str())
.unwrap_or("");
let alt = attrs
.and_then(|a| a.get("alt"))
.and_then(|s| s.as_str())
.unwrap_or("");
let title = attrs
.and_then(|a| a.get("title"))
.and_then(|s| s.as_str());
out.push_str(&format!(
"<img src=\"{}\" alt=\"{}\"",
escape_attr(src),
escape_attr(alt)
));
if let Some(t) = title {
out.push_str(&format!(" title=\"{}\"", escape_attr(t)));
}
out.push_str(">\n");
}
"hardBreak" | "hard_break" => {
out.push_str("<br>");
}
_ => {
// Ukjent nodetype — render barn rekursivt
render_children(node, out);
}
}
}
fn render_children(node: &Value, out: &mut String) {
if let Some(content) = node.get("content").and_then(|c| c.as_array()) {
for child in content {
render_node(child, out);
}
}
}
fn render_inline_content(node: &Value, out: &mut String) {
let Some(content) = node.get("content").and_then(|c| c.as_array()) else {
return;
};
for child in content {
let child_type = child.get("type").and_then(|t| t.as_str()).unwrap_or("");
match child_type {
"text" => {
let text = child.get("text").and_then(|t| t.as_str()).unwrap_or("");
let marks = child.get("marks").and_then(|m| m.as_array());
render_text_with_marks(text, marks, out);
}
"hardBreak" | "hard_break" => {
out.push_str("<br>");
}
"image" => {
render_node(child, out);
}
_ => {
// Ukjent inline-type — render rekursivt
render_node(child, out);
}
}
}
}
fn render_text_with_marks(text: &str, marks: Option<&Vec<Value>>, out: &mut String) {
let Some(marks) = marks else {
out.push_str(&escape_html(text));
return;
};
// Åpne marks
let mut close_tags: Vec<&str> = Vec::new();
for mark in marks {
let mark_type = mark.get("type").and_then(|t| t.as_str()).unwrap_or("");
match mark_type {
"bold" | "strong" => {
out.push_str("<strong>");
close_tags.push("</strong>");
}
"italic" | "em" => {
out.push_str("<em>");
close_tags.push("</em>");
}
"strike" | "strikethrough" => {
out.push_str("<s>");
close_tags.push("</s>");
}
"code" => {
out.push_str("<code>");
close_tags.push("</code>");
}
"underline" => {
out.push_str("<u>");
close_tags.push("</u>");
}
"link" => {
let href = mark
.get("attrs")
.and_then(|a| a.get("href"))
.and_then(|h| h.as_str())
.unwrap_or("#");
let target = mark
.get("attrs")
.and_then(|a| a.get("target"))
.and_then(|t| t.as_str());
out.push_str(&format!("<a href=\"{}\"", escape_attr(href)));
if let Some(t) = target {
out.push_str(&format!(" target=\"{}\"", escape_attr(t)));
}
out.push_str(&format!(" rel=\"noopener noreferrer\">"));
close_tags.push("</a>");
}
_ => {} // Ukjent mark — ignorer
}
}
out.push_str(&escape_html(text));
// Lukk marks i motsatt rekkefølge
for tag in close_tags.iter().rev() {
out.push_str(tag);
}
}
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn escape_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn simple_paragraph() {
let doc = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "Hello world" }]
}]
});
assert_eq!(document_to_html(&doc), "<p>Hello world</p>\n");
}
#[test]
fn heading_levels() {
let doc = json!({
"type": "doc",
"content": [{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Title" }]
}]
});
assert_eq!(document_to_html(&doc), "<h2>Title</h2>\n");
}
#[test]
fn bold_and_italic_marks() {
let doc = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": "bold text",
"marks": [{ "type": "bold" }]
}]
}]
});
assert_eq!(document_to_html(&doc), "<p><strong>bold text</strong></p>\n");
}
#[test]
fn link_mark() {
let doc = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": "click here",
"marks": [{ "type": "link", "attrs": { "href": "https://example.com" } }]
}]
}]
});
let html = document_to_html(&doc);
assert!(html.contains("href=\"https://example.com\""));
assert!(html.contains("rel=\"noopener noreferrer\""));
assert!(html.contains("click here</a>"));
}
#[test]
fn blockquote() {
let doc = json!({
"type": "doc",
"content": [{
"type": "blockquote",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "quoted" }]
}]
}]
});
let html = document_to_html(&doc);
assert!(html.contains("<blockquote>"));
assert!(html.contains("<p>quoted</p>"));
}
#[test]
fn bullet_list() {
let doc = json!({
"type": "doc",
"content": [{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "item 1" }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "item 2" }] }] }
]
}]
});
let html = document_to_html(&doc);
assert!(html.contains("<ul>"));
assert!(html.contains("<li>"));
assert!(html.contains("item 1"));
assert!(html.contains("item 2"));
}
#[test]
fn html_escaping() {
let doc = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "<script>alert('xss')</script>" }]
}]
});
let html = document_to_html(&doc);
assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;"));
}
#[test]
fn empty_doc() {
let doc = json!({ "type": "doc" });
assert_eq!(document_to_html(&doc), "");
}
#[test]
fn image_node() {
let doc = json!({
"type": "doc",
"content": [{
"type": "image",
"attrs": { "src": "/cas/abc123", "alt": "Test image" }
}]
});
let html = document_to_html(&doc);
assert!(html.contains("src=\"/cas/abc123\""));
assert!(html.contains("alt=\"Test image\""));
}
#[test]
fn code_block() {
let doc = json!({
"type": "doc",
"content": [{
"type": "codeBlock",
"attrs": { "language": "rust" },
"content": [{ "type": "text", "text": "fn main() {}" }]
}]
});
let html = document_to_html(&doc);
assert!(html.contains("<pre><code class=\"language-rust\">"));
assert!(html.contains("fn main() {}"));
}
}

View file

@ -35,6 +35,7 @@ Fase 5 + 6 + 7 → Fase 11 (produksjon)
Fase 3 + 4 → Fase 13 (traits)
Fase 6 + 13 → Fase 14 (publisering)
Fase 3 + 10 → Fase 15 (adminpanel)
Fase 11 + 13 → Fase 16 (lydmixer)
Alt → Fase 12 (herding)
```
@ -138,8 +139,7 @@ Uavhengige faser kan fortsatt plukkes.
## Fase 14: Publisering
- [x] 14.1 Tera-templates: innebygde temaer (avis, magasin, blogg, tidsskrift) med Tera i Rust. Artikkelmal + forside-mal per tema. CSS-variabler for theme_config-overstyring. Ref: `docs/concepts/publisering.md` § "Temaer".
- [~] 14.2 HTML-rendering av enkeltartikler: maskinrommet rendrer `metadata.document` til HTML via Tera, lagrer i CAS. Noden får `metadata.rendered.html_hash` + `renderer_version`. SEO-metadata (OG-tags, canonical, JSON-LD).
> Påbegynt: 2026-03-18T00:43
- [x] 14.2 HTML-rendering av enkeltartikler: maskinrommet rendrer `metadata.document` til HTML via Tera, lagrer i CAS. Noden får `metadata.rendered.html_hash` + `renderer_version`. SEO-metadata (OG-tags, canonical, JSON-LD).
- [ ] 14.3 Forside-rendering: maskinrommet spør PG for hero/featured/strøm (tre indekserte spørringer), appliserer tema-template, rendrer til CAS (statisk modus) eller serverer med in-memory cache (dynamisk modus). `index_mode` og `index_cache_ttl` i trait-konfig.
- [ ] 14.4 Caddy-ruting for synops.no/pub: Caddy reverse-proxyer til maskinrommet som gjør slug→hash-oppslag og streamer CAS-fil. `Cache-Control: immutable` for artikler. Kategori/arkiv/søk serveres dynamisk av maskinrommet med kortere cache-TTL.
- [ ] 14.5 Slot-håndtering i maskinrommet: `slot` og `slot_order` i `belongs_to`-edge metadata. Ved ny hero → gammel hero flyttes til strøm. Ved featured over `featured_max` → FIFO tilbake til strøm. `pinned`-flagg forhindrer automatisk fjerning.
@ -168,6 +168,18 @@ Uavhengige faser kan fortsatt plukkes.
- [ ] 15.8 Forbruksoversikt i admin: aggregert visning per samling, per ressurstype, per tidsperiode. Drill-down til jobbtype og modellnivå.
- [ ] 15.9 Brukersynlig forbruk: hver bruker ser eget forbruk i profil/innstillinger. Per-node forbruk synlig i node-detaljer for eiere.
## Fase 16: Lydmixer
Ref: `docs/features/lydmixer.md`
- [ ] 16.1 LiveKit-klient i frontend: installer `livekit-client`, koble til rom, vis deltakerliste. Deaktiver LiveKit sin auto-attach av `<audio>`-elementer — lyd rutes gjennom Web Audio API i stedet.
- [ ] 16.2 Web Audio mixer-graf: opprett `AudioContext`, `MediaStreamSourceNode` per remote track → per-kanal `GainNode` → master `GainNode``destination`. `AnalyserNode` per kanal for VU-meter.
- [ ] 16.3 Mixer-UI (MixerTrait-komponent): kanalstripe per deltaker med volumslider (0150%), nød-mute-knapp (stor, rød), VU-meter (canvas/CSS), navnelabel. Master-fader og master-mute. Responsivt design (mobil: kompakt fader-modus).
- [ ] 16.4 Delt mixer-kontroll via SpacetimeDB: `MixerChannel`-tabell + reducers (`set_gain`, `set_mute`, `toggle_effect`). Frontend abonnerer og oppdaterer Web Audio-graf ved endring fra andre deltakere. Visuell feedback (sliders beveger seg i sanntid). Tilgangskontroll: eier/admin kan sette deltaker til viewer-modus.
- [ ] 16.5 Sound pads: pad-grid UI (4×2), forhåndslast lydfiler fra CAS til `AudioBuffer`. Avspilling ved trykk (`AudioBufferSourceNode`). Pad-konfig i `metadata.mixer.pads` (label, farge, cas_hash). Synkronisert avspilling via LiveKit Data Message.
- [ ] 16.6 EQ-effektkjede: fat bottom (`BiquadFilterNode` lowshelf ~200Hz), sparkle (`BiquadFilterNode` highshelf ~10kHz), exciter (`WaveShaperNode` + highshelf). Per-kanal toggles, synkronisert via STDB. Presets (podcast-stemme, radio-stemme).
- [ ] 16.7 Stemmeeffekter: robotstemme (ring-modulasjon: `OscillatorNode``GainNode.gain`), monsterstemme (egenutviklet `AudioWorkletProcessor` med phase vocoder for pitch shift). Effektvelger-UI per kanal. Parameterjustering (pitch-faktor, oscillator-frekvens).
## Fase 12: Herding
- [ ] 12.1 Observerbarhet: strukturert logging, metrikker (request latency, queue depth, AI cost).