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:
parent
1cb0d43f21
commit
e050612dec
12 changed files with 965 additions and 16 deletions
|
|
@ -654,9 +654,9 @@ Noden peker på rendret resultat via metadata:
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"document": { /* TipTap/ProseMirror JSON */ },
|
"document": { /* TipTap/ProseMirror JSON */ },
|
||||||
"rendered": {
|
"rendered": {
|
||||||
"html_hash": "cas://sha256-abc123",
|
"html_hash": "a1b2c3d4e5f6...", // SHA-256 hex-digest, peker til CAS
|
||||||
"rendered_at": "2026-03-17T14:30:00Z",
|
"rendered_at": "2026-03-17T14:30:00Z",
|
||||||
"renderer_version": 2
|
"renderer_version": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1704,6 +1704,11 @@ fn spawn_pg_insert_edge(
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!(edge_id = %edge_id, "Edge persistert til PostgreSQL");
|
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) => {
|
Err(e) => {
|
||||||
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL");
|
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.
|
/// Synkroniserer node_access-rader for et subject fra PG til STDB.
|
||||||
/// Kalles etter recompute_access for å holde STDB i synk.
|
/// 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) {
|
async fn sync_node_access_to_stdb(db: &PgPool, stdb: &crate::stdb::StdbClient, subject_id: Uuid) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use crate::agent;
|
||||||
use crate::ai_edges;
|
use crate::ai_edges;
|
||||||
use crate::audio;
|
use crate::audio;
|
||||||
use crate::cas::CasStore;
|
use crate::cas::CasStore;
|
||||||
|
use crate::publishing;
|
||||||
use crate::stdb::StdbClient;
|
use crate::stdb::StdbClient;
|
||||||
use crate::summarize;
|
use crate::summarize;
|
||||||
use crate::transcribe;
|
use crate::transcribe;
|
||||||
|
|
@ -171,10 +172,40 @@ async fn dispatch(
|
||||||
"audio_process" => {
|
"audio_process" => {
|
||||||
audio::handle_audio_process_job(job, db, stdb, cas).await
|
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}")),
|
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.
|
/// Starter worker-loopen som poller job_queue.
|
||||||
/// Kjører som en bakgrunnsoppgave i tokio.
|
/// Kjører som en bakgrunnsoppgave i tokio.
|
||||||
pub fn start_worker(db: PgPool, stdb: StdbClient, cas: CasStore) {
|
pub fn start_worker(db: PgPool, stdb: StdbClient, cas: CasStore) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ mod rss;
|
||||||
mod serving;
|
mod serving;
|
||||||
mod stdb;
|
mod stdb;
|
||||||
pub mod summarize;
|
pub mod summarize;
|
||||||
|
pub mod tiptap;
|
||||||
pub mod transcribe;
|
pub mod transcribe;
|
||||||
pub mod tts;
|
pub mod tts;
|
||||||
mod warmup;
|
mod warmup;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
//! Hvert tema har artikkelmal + forside-mal.
|
//! Hvert tema har artikkelmal + forside-mal.
|
||||||
//! CSS-variabler for theme_config-overstyring.
|
//! 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::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
|
@ -17,8 +21,14 @@ use sqlx::PgPool;
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::cas::CasStore;
|
||||||
|
use crate::tiptap;
|
||||||
use crate::AppState;
|
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
|
// Tema-konfigurasjon fra publishing-trait
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -66,6 +76,63 @@ pub struct LayoutConfig {
|
||||||
pub max_width: Option<String>,
|
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
|
// Innebygde temaer — Tera-templates
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -221,7 +288,7 @@ pub struct IndexData {
|
||||||
// Render-funksjoner
|
// Render-funksjoner
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// Render en artikkel med gitt tema.
|
/// Render en artikkel med gitt tema og SEO-metadata.
|
||||||
pub fn render_article(
|
pub fn render_article(
|
||||||
tera: &Tera,
|
tera: &Tera,
|
||||||
theme: &str,
|
theme: &str,
|
||||||
|
|
@ -229,6 +296,7 @@ pub fn render_article(
|
||||||
article: &ArticleData,
|
article: &ArticleData,
|
||||||
collection_title: &str,
|
collection_title: &str,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
seo: &SeoData,
|
||||||
) -> Result<String, tera::Error> {
|
) -> Result<String, tera::Error> {
|
||||||
let css_vars = build_css_variables(theme, config);
|
let css_vars = build_css_variables(theme, config);
|
||||||
let template_name = format!("{theme}/article.html");
|
let template_name = format!("{theme}/article.html");
|
||||||
|
|
@ -240,6 +308,7 @@ pub fn render_article(
|
||||||
ctx.insert("collection_title", collection_title);
|
ctx.insert("collection_title", collection_title);
|
||||||
ctx.insert("base_url", base_url);
|
ctx.insert("base_url", base_url);
|
||||||
ctx.insert("logo_hash", &config.logo_hash);
|
ctx.insert("logo_hash", &config.logo_hash);
|
||||||
|
ctx.insert("seo", seo);
|
||||||
|
|
||||||
tera.render(&template_name, &ctx)
|
tera.render(&template_name, &ctx)
|
||||||
}
|
}
|
||||||
|
|
@ -265,6 +334,199 @@ pub fn render_index(
|
||||||
tera.render(&template_name, &ctx)
|
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
|
// 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).
|
/// Hent artikkeldata for en enkelt node (belongs_to samlingen).
|
||||||
|
/// Returnerer også nodens metadata (for å sjekke rendered.html_hash).
|
||||||
async fn fetch_article(
|
async fn fetch_article(
|
||||||
db: &PgPool,
|
db: &PgPool,
|
||||||
collection_id: Uuid,
|
collection_id: Uuid,
|
||||||
article_short_id: &str,
|
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
|
// short_id er de første 8 tegnene av UUID
|
||||||
let pattern = format!("{article_short_id}%");
|
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#"
|
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
|
FROM edges e
|
||||||
JOIN nodes n ON n.id = e.source_id
|
JOIN nodes n ON n.id = e.source_id
|
||||||
WHERE e.target_id = $1
|
WHERE e.target_id = $1
|
||||||
|
|
@ -336,7 +632,7 @@ async fn fetch_article(
|
||||||
.fetch_optional(db)
|
.fetch_optional(db)
|
||||||
.await?;
|
.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);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -347,17 +643,52 @@ async fn fetch_article(
|
||||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||||
.unwrap_or(created_at);
|
.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 {
|
let article = ArticleData {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
short_id: id.to_string()[..8].to_string(),
|
short_id: id.to_string()[..8].to_string(),
|
||||||
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
|
title: title.unwrap_or_else(|| "Uten tittel".to_string()),
|
||||||
content: content.unwrap_or_default(),
|
content: article_html,
|
||||||
summary: None,
|
summary: Some(summary_text),
|
||||||
published_at: publish_at.to_rfc3339(),
|
published_at: publish_at.to_rfc3339(),
|
||||||
published_at_short: publish_at.format("%e. %B %Y").to_string(),
|
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.
|
/// Hent artikler for forsiden, sortert i slots.
|
||||||
|
|
@ -505,6 +836,9 @@ pub async fn serve_index(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /pub/{slug}/{article_id} — enkeltartikkel.
|
/// 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(
|
pub async fn serve_article(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((slug, article_id)): Path<(String, String)>,
|
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 theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
|
||||||
let config = &collection.publishing_config.theme_config;
|
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
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!(slug = %slug, article = %article_id, error = %e, "Feil ved henting av artikkel");
|
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)?;
|
.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 collection_title = collection.title.unwrap_or_else(|| slug.clone());
|
||||||
let base_url = collection
|
let base_url = collection
|
||||||
.publishing_config
|
.publishing_config
|
||||||
|
|
@ -536,8 +891,11 @@ pub async fn serve_article(
|
||||||
.map(|d| format!("https://{d}"))
|
.map(|d| format!("https://{d}"))
|
||||||
.unwrap_or_else(|| format!("/pub/{slug}"));
|
.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 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| {
|
.map_err(|e| {
|
||||||
tracing::error!(slug = %slug, article = %article_id, theme = %theme, error = %e, "Tera render-feil (artikkel)");
|
tracing::error!(slug = %slug, article = %article_id, theme = %theme, error = %e, "Tera render-feil (artikkel)");
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
|
@ -608,6 +966,16 @@ pub async fn preview_theme(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn css_variables_use_defaults() {
|
fn css_variables_use_defaults() {
|
||||||
let config = ThemeConfig::default();
|
let config = ThemeConfig::default();
|
||||||
|
|
@ -662,8 +1030,10 @@ mod tests {
|
||||||
published_at_short: "18. mars 2026".to_string(),
|
published_at_short: "18. mars 2026".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let seo = default_seo();
|
||||||
|
|
||||||
for theme in &["avis", "magasin", "blogg", "tidsskrift"] {
|
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}"));
|
.unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}"));
|
||||||
assert!(html.contains("Testittel"), "Tittel mangler i {theme}");
|
assert!(html.contains("Testittel"), "Tittel mangler i {theme}");
|
||||||
assert!(html.contains("Testinnhold"), "Innhold 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]
|
#[test]
|
||||||
fn render_index_all_themes() {
|
fn render_index_all_themes() {
|
||||||
let tera = build_tera();
|
let tera = build_tera();
|
||||||
|
|
@ -706,4 +1108,23 @@ mod tests {
|
||||||
let css_blogg = build_css_variables("blogg", &config);
|
let css_blogg = build_css_variables("blogg", &config);
|
||||||
assert_eq!(css, css_blogg);
|
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\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
{% 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 %}
|
{% block extra_css %}
|
||||||
.article {
|
.article {
|
||||||
max-width: var(--layout-max-width);
|
max-width: var(--layout-max-width);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}{{ collection_title | default(value="Synops") }}{% endblock %}</title>
|
<title>{% block title %}{{ collection_title | default(value="Synops") }}{% endblock %}</title>
|
||||||
|
{% block seo %}{% endblock %}
|
||||||
<style>
|
<style>
|
||||||
{{ css_variables | safe }}
|
{{ css_variables | safe }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
{% 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 %}
|
{% block extra_css %}
|
||||||
.blog-article {
|
.blog-article {
|
||||||
max-width: var(--layout-max-width);
|
max-width: var(--layout-max-width);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
{% 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 %}
|
{% block extra_css %}
|
||||||
.mag-article {
|
.mag-article {
|
||||||
max-width: var(--layout-max-width);
|
max-width: var(--layout-max-width);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
{% 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 %}
|
{% block extra_css %}
|
||||||
.journal-article {
|
.journal-article {
|
||||||
max-width: var(--layout-max-width);
|
max-width: var(--layout-max-width);
|
||||||
|
|
|
||||||
380
maskinrommet/src/tiptap.rs
Normal file
380
maskinrommet/src/tiptap.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_attr(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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("<script>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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() {}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tasks.md
16
tasks.md
|
|
@ -35,6 +35,7 @@ Fase 5 + 6 + 7 → Fase 11 (produksjon)
|
||||||
Fase 3 + 4 → Fase 13 (traits)
|
Fase 3 + 4 → Fase 13 (traits)
|
||||||
Fase 6 + 13 → Fase 14 (publisering)
|
Fase 6 + 13 → Fase 14 (publisering)
|
||||||
Fase 3 + 10 → Fase 15 (adminpanel)
|
Fase 3 + 10 → Fase 15 (adminpanel)
|
||||||
|
Fase 11 + 13 → Fase 16 (lydmixer)
|
||||||
Alt → Fase 12 (herding)
|
Alt → Fase 12 (herding)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -138,8 +139,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
## Fase 14: Publisering
|
## 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".
|
- [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).
|
- [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).
|
||||||
> Påbegynt: 2026-03-18T00:43
|
|
||||||
- [ ] 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.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.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.
|
- [ ] 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.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.
|
- [ ] 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 (0–150%), 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
|
## Fase 12: Herding
|
||||||
|
|
||||||
- [ ] 12.1 Observerbarhet: strukturert logging, metrikker (request latency, queue depth, AI cost).
|
- [ ] 12.1 Observerbarhet: strukturert logging, metrikker (request latency, queue depth, AI cost).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue