Tera-templates: innebygde temaer for publisering (oppgave 14.1)
Implementerer publiseringsmotoren med fire innebygde temaer:
- Avis: multi-kolonne, informasjonstung, hero+sidebar+rutenett
- Magasin: store bilder, luft, editorial, cards-layout
- Blogg: enkel, én kolonne, kronologisk liste
- Tidsskrift: akademisk, tekstdrevet, nummerert innholdsfortegnelse
Hvert tema har artikkelmal + forside-mal som Tera-templates (Jinja2-like).
CSS-variabler for theme_config-overstyring fra publishing-traiten —
fungerer meningsfullt med bare "theme": "magasin" (null konfigurasjon).
Teknisk:
- publishing.rs: Tera engine, render-funksjoner, DB-spørringer, HTTP-handlers
- Templates innebygd via include_str! (kompilert inn i binæren)
- Ruter: GET /pub/{slug} (forside), /pub/{slug}/{id} (artikkel),
/pub/{slug}/preview/{theme} (forhåndsvisning med testdata)
- 6 enhetstester for CSS-variabler, rendering og tema-fallback
Ref: docs/concepts/publisering.md § "Temaer"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
260d3d030c
commit
4b9f520eab
14 changed files with 1833 additions and 2 deletions
287
maskinrommet/Cargo.lock
generated
287
maskinrommet/Cargo.lock
generated
|
|
@ -136,6 +136,16 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
|
|
@ -190,6 +200,28 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz-build"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -235,6 +267,25 @@ version = "2.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
|
|
@ -280,6 +331,12 @@ dependencies = [
|
|||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deunicode"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -530,6 +587,30 @@ dependencies = [
|
|||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globwalk"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ignore",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
|
|
@ -640,6 +721,15 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humansize"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
|
|
@ -834,6 +924,22 @@ dependencies = [
|
|||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
|
|
@ -982,6 +1088,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tera",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
|
|
@ -1171,6 +1278,15 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse-zoneinfo"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
|
|
@ -1196,6 +1312,87 @@ version = "2.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -1437,6 +1634,18 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
|
|
@ -1583,6 +1792,15 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1730,12 +1948,28 @@ dependencies = [
|
|||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "slug"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
|
||||
dependencies = [
|
||||
"deunicode",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
|
|
@ -2026,6 +2260,28 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tera"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"globwalk",
|
||||
"humansize",
|
||||
"lazy_static",
|
||||
"percent-encoding",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"slug",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
|
|
@ -2307,6 +2563,12 @@ version = "1.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
|
|
@ -2340,6 +2602,12 @@ version = "0.1.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
|
@ -2400,6 +2668,16 @@ version = "0.9.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
|
|
@ -2593,6 +2871,15 @@ dependencies = [
|
|||
"wasite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
|||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tera = "1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod agent;
|
||||
pub mod ai_edges;
|
||||
pub mod audio;
|
||||
mod auth;
|
||||
pub mod cas;
|
||||
mod intentions;
|
||||
|
|
@ -7,6 +8,7 @@ pub mod jobs;
|
|||
pub mod livekit;
|
||||
pub mod pruning;
|
||||
mod queries;
|
||||
pub mod publishing;
|
||||
mod rss;
|
||||
mod serving;
|
||||
mod stdb;
|
||||
|
|
@ -166,7 +168,13 @@ async fn main() {
|
|||
.route("/query/graph", get(queries::query_graph))
|
||||
.route("/query/transcription_versions", get(queries::query_transcription_versions))
|
||||
.route("/query/segments_version", get(queries::query_segments_version))
|
||||
.route("/intentions/audio_analyze", post(intentions::audio_analyze))
|
||||
.route("/intentions/audio_process", post(intentions::audio_process))
|
||||
.route("/query/audio_info", get(intentions::audio_info))
|
||||
.route("/pub/{slug}/feed.xml", get(rss::generate_feed))
|
||||
.route("/pub/{slug}", get(publishing::serve_index))
|
||||
.route("/pub/{slug}/{article_id}", get(publishing::serve_article))
|
||||
.route("/pub/{slug}/preview/{theme}", get(publishing::preview_theme))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
|
|
|
|||
709
maskinrommet/src/publishing.rs
Normal file
709
maskinrommet/src/publishing.rs
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
//! Publiseringsmotor: Tera-templates med innebygde temaer.
|
||||
//!
|
||||
//! Fire temaer: avis, magasin, blogg, tidsskrift.
|
||||
//! Hvert tema har artikkelmal + forside-mal.
|
||||
//! CSS-variabler for theme_config-overstyring.
|
||||
//!
|
||||
//! Ref: docs/concepts/publisering.md § "Temaer"
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::Response,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use tera::{Context, Tera};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// =============================================================================
|
||||
// Tema-konfigurasjon fra publishing-trait
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
pub struct PublishingConfig {
|
||||
pub slug: Option<String>,
|
||||
pub theme: Option<String>,
|
||||
#[serde(default)]
|
||||
pub theme_config: ThemeConfig,
|
||||
pub custom_domain: Option<String>,
|
||||
pub index_mode: Option<String>,
|
||||
pub featured_max: Option<i64>,
|
||||
pub stream_page_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
||||
pub struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
pub colors: ColorConfig,
|
||||
#[serde(default)]
|
||||
pub typography: TypographyConfig,
|
||||
#[serde(default)]
|
||||
pub layout: LayoutConfig,
|
||||
pub logo_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
||||
pub struct ColorConfig {
|
||||
pub primary: Option<String>,
|
||||
pub accent: Option<String>,
|
||||
pub background: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub muted: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
||||
pub struct TypographyConfig {
|
||||
pub heading_font: Option<String>,
|
||||
pub body_font: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug, Clone, Serialize)]
|
||||
pub struct LayoutConfig {
|
||||
pub max_width: Option<String>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Innebygde temaer — Tera-templates
|
||||
// =============================================================================
|
||||
|
||||
/// Tema-defaults for CSS-variabler per tema.
|
||||
struct ThemeDefaults {
|
||||
primary: &'static str,
|
||||
accent: &'static str,
|
||||
background: &'static str,
|
||||
text: &'static str,
|
||||
muted: &'static str,
|
||||
heading_font: &'static str,
|
||||
body_font: &'static str,
|
||||
max_width: &'static str,
|
||||
}
|
||||
|
||||
fn theme_defaults(theme: &str) -> ThemeDefaults {
|
||||
match theme {
|
||||
"avis" => ThemeDefaults {
|
||||
primary: "#1a1a2e",
|
||||
accent: "#e94560",
|
||||
background: "#ffffff",
|
||||
text: "#1a1a2e",
|
||||
muted: "#6b7280",
|
||||
heading_font: "'Georgia', 'Times New Roman', serif",
|
||||
body_font: "'Charter', 'Georgia', serif",
|
||||
max_width: "1200px",
|
||||
},
|
||||
"magasin" => ThemeDefaults {
|
||||
primary: "#2d3436",
|
||||
accent: "#0984e3",
|
||||
background: "#fafafa",
|
||||
text: "#2d3436",
|
||||
muted: "#636e72",
|
||||
heading_font: "'Playfair Display', 'Georgia', serif",
|
||||
body_font: "system-ui, -apple-system, sans-serif",
|
||||
max_width: "1100px",
|
||||
},
|
||||
"blogg" => ThemeDefaults {
|
||||
primary: "#2c3e50",
|
||||
accent: "#3498db",
|
||||
background: "#ffffff",
|
||||
text: "#333333",
|
||||
muted: "#7f8c8d",
|
||||
heading_font: "system-ui, -apple-system, sans-serif",
|
||||
body_font: "system-ui, -apple-system, sans-serif",
|
||||
max_width: "720px",
|
||||
},
|
||||
"tidsskrift" => ThemeDefaults {
|
||||
primary: "#1a1a1a",
|
||||
accent: "#8b0000",
|
||||
background: "#fffff8",
|
||||
text: "#1a1a1a",
|
||||
muted: "#555555",
|
||||
heading_font: "'Georgia', 'Times New Roman', serif",
|
||||
body_font: "'Georgia', 'Times New Roman', serif",
|
||||
max_width: "680px",
|
||||
},
|
||||
// Fallback til blogg-defaults
|
||||
_ => theme_defaults("blogg"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generer CSS-variabler fra theme_config med tema-defaults som fallback.
|
||||
fn build_css_variables(theme: &str, config: &ThemeConfig) -> String {
|
||||
let defaults = theme_defaults(theme);
|
||||
format!(
|
||||
r#":root {{
|
||||
--color-primary: {primary};
|
||||
--color-accent: {accent};
|
||||
--color-background: {background};
|
||||
--color-text: {text};
|
||||
--color-muted: {muted};
|
||||
--font-heading: {heading_font};
|
||||
--font-body: {body_font};
|
||||
--layout-max-width: {max_width};
|
||||
}}"#,
|
||||
primary = config.colors.primary.as_deref().unwrap_or(defaults.primary),
|
||||
accent = config.colors.accent.as_deref().unwrap_or(defaults.accent),
|
||||
background = config.colors.background.as_deref().unwrap_or(defaults.background),
|
||||
text = config.colors.text.as_deref().unwrap_or(defaults.text),
|
||||
muted = config.colors.muted.as_deref().unwrap_or(defaults.muted),
|
||||
heading_font = config.typography.heading_font.as_deref().unwrap_or(defaults.heading_font),
|
||||
body_font = config.typography.body_font.as_deref().unwrap_or(defaults.body_font),
|
||||
max_width = config.layout.max_width.as_deref().unwrap_or(defaults.max_width),
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tera engine med innebygde templates
|
||||
// =============================================================================
|
||||
|
||||
/// Bygg Tera-instans med alle innebygde temaer.
|
||||
pub fn build_tera() -> Tera {
|
||||
let mut tera = Tera::default();
|
||||
|
||||
// Base-template (felles for alle temaer)
|
||||
tera.add_raw_template("base.html", include_str!("templates/base.html"))
|
||||
.expect("Feil i base.html template");
|
||||
|
||||
// Avis
|
||||
tera.add_raw_template("avis/article.html", include_str!("templates/avis/article.html"))
|
||||
.expect("Feil i avis/article.html");
|
||||
tera.add_raw_template("avis/index.html", include_str!("templates/avis/index.html"))
|
||||
.expect("Feil i avis/index.html");
|
||||
|
||||
// Magasin
|
||||
tera.add_raw_template("magasin/article.html", include_str!("templates/magasin/article.html"))
|
||||
.expect("Feil i magasin/article.html");
|
||||
tera.add_raw_template("magasin/index.html", include_str!("templates/magasin/index.html"))
|
||||
.expect("Feil i magasin/index.html");
|
||||
|
||||
// Blogg
|
||||
tera.add_raw_template("blogg/article.html", include_str!("templates/blogg/article.html"))
|
||||
.expect("Feil i blogg/article.html");
|
||||
tera.add_raw_template("blogg/index.html", include_str!("templates/blogg/index.html"))
|
||||
.expect("Feil i blogg/index.html");
|
||||
|
||||
// Tidsskrift
|
||||
tera.add_raw_template("tidsskrift/article.html", include_str!("templates/tidsskrift/article.html"))
|
||||
.expect("Feil i tidsskrift/article.html");
|
||||
tera.add_raw_template("tidsskrift/index.html", include_str!("templates/tidsskrift/index.html"))
|
||||
.expect("Feil i tidsskrift/index.html");
|
||||
|
||||
tera
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Datamodeller for rendering
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ArticleData {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub summary: Option<String>,
|
||||
pub published_at: String,
|
||||
pub published_at_short: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IndexData {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub hero: Option<ArticleData>,
|
||||
pub featured: Vec<ArticleData>,
|
||||
pub stream: Vec<ArticleData>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Render-funksjoner
|
||||
// =============================================================================
|
||||
|
||||
/// Render en artikkel med gitt tema.
|
||||
pub fn render_article(
|
||||
tera: &Tera,
|
||||
theme: &str,
|
||||
config: &ThemeConfig,
|
||||
article: &ArticleData,
|
||||
collection_title: &str,
|
||||
base_url: &str,
|
||||
) -> Result<String, tera::Error> {
|
||||
let css_vars = build_css_variables(theme, config);
|
||||
let template_name = format!("{theme}/article.html");
|
||||
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert("css_variables", &css_vars);
|
||||
ctx.insert("theme", theme);
|
||||
ctx.insert("article", article);
|
||||
ctx.insert("collection_title", collection_title);
|
||||
ctx.insert("base_url", base_url);
|
||||
ctx.insert("logo_hash", &config.logo_hash);
|
||||
|
||||
tera.render(&template_name, &ctx)
|
||||
}
|
||||
|
||||
/// Render forsiden med gitt tema.
|
||||
pub fn render_index(
|
||||
tera: &Tera,
|
||||
theme: &str,
|
||||
config: &ThemeConfig,
|
||||
index: &IndexData,
|
||||
base_url: &str,
|
||||
) -> Result<String, tera::Error> {
|
||||
let css_vars = build_css_variables(theme, config);
|
||||
let template_name = format!("{theme}/index.html");
|
||||
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert("css_variables", &css_vars);
|
||||
ctx.insert("theme", theme);
|
||||
ctx.insert("index", index);
|
||||
ctx.insert("base_url", base_url);
|
||||
ctx.insert("logo_hash", &config.logo_hash);
|
||||
|
||||
tera.render(&template_name, &ctx)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Database-spørringer
|
||||
// =============================================================================
|
||||
|
||||
struct CollectionRow {
|
||||
id: Uuid,
|
||||
title: Option<String>,
|
||||
publishing_config: PublishingConfig,
|
||||
}
|
||||
|
||||
/// Finn samling med publishing-trait basert på slug.
|
||||
async fn find_publishing_collection(
|
||||
db: &PgPool,
|
||||
slug: &str,
|
||||
) -> Result<Option<CollectionRow>, sqlx::Error> {
|
||||
let row: Option<(Uuid, Option<String>, serde_json::Value)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, title, metadata
|
||||
FROM nodes
|
||||
WHERE node_kind = 'collection'
|
||||
AND metadata->'traits'->'publishing'->>'slug' = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let Some((id, title, metadata)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let publishing_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(CollectionRow {
|
||||
id,
|
||||
title,
|
||||
publishing_config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Hent artikkeldata for en enkelt node (belongs_to samlingen).
|
||||
async fn fetch_article(
|
||||
db: &PgPool,
|
||||
collection_id: Uuid,
|
||||
article_short_id: &str,
|
||||
) -> Result<Option<(ArticleData, Option<serde_json::Value>)>, 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(
|
||||
r#"
|
||||
SELECT n.id, n.title, n.content, n.created_at, e.metadata
|
||||
FROM edges e
|
||||
JOIN nodes n ON n.id = e.source_id
|
||||
WHERE e.target_id = $1
|
||||
AND e.edge_type = 'belongs_to'
|
||||
AND n.id::text LIKE $2
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(collection_id)
|
||||
.bind(&pattern)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let Some((id, title, content, created_at, edge_meta)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let publish_at = edge_meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("publish_at"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||
.unwrap_or(created_at);
|
||||
|
||||
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,
|
||||
published_at: publish_at.to_rfc3339(),
|
||||
published_at_short: publish_at.format("%e. %B %Y").to_string(),
|
||||
};
|
||||
|
||||
Ok(Some((article, edge_meta)))
|
||||
}
|
||||
|
||||
/// Hent artikler for forsiden, sortert i slots.
|
||||
async fn fetch_index_articles(
|
||||
db: &PgPool,
|
||||
collection_id: Uuid,
|
||||
featured_max: i64,
|
||||
stream_page_size: i64,
|
||||
) -> Result<(Option<ArticleData>, Vec<ArticleData>, Vec<ArticleData>), sqlx::Error> {
|
||||
// Hent alle publiserte artikler med edge-metadata
|
||||
let rows: Vec<(Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT n.id, n.title, n.content, n.created_at, e.metadata
|
||||
FROM edges e
|
||||
JOIN nodes n ON n.id = e.source_id
|
||||
WHERE e.target_id = $1
|
||||
AND e.edge_type = 'belongs_to'
|
||||
ORDER BY COALESCE(
|
||||
(e.metadata->>'publish_at')::timestamptz,
|
||||
n.created_at
|
||||
) DESC
|
||||
"#,
|
||||
)
|
||||
.bind(collection_id)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
let mut hero: Option<ArticleData> = None;
|
||||
let mut featured: Vec<ArticleData> = Vec::new();
|
||||
let mut stream: Vec<ArticleData> = Vec::new();
|
||||
|
||||
for (id, title, content, created_at, edge_meta) in rows {
|
||||
let slot = edge_meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("slot"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let publish_at = edge_meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("publish_at"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||
.unwrap_or(created_at);
|
||||
|
||||
let summary = content
|
||||
.as_deref()
|
||||
.map(|c| truncate(c, 200));
|
||||
|
||||
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,
|
||||
published_at: publish_at.to_rfc3339(),
|
||||
published_at_short: publish_at.format("%e. %B %Y").to_string(),
|
||||
};
|
||||
|
||||
match slot {
|
||||
"hero" if hero.is_none() => hero = Some(article),
|
||||
"featured" if (featured.len() as i64) < featured_max => featured.push(article),
|
||||
_ => {
|
||||
if (stream.len() as i64) < stream_page_size {
|
||||
stream.push(article);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((hero, featured, stream))
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
match s[..max].rfind(' ') {
|
||||
Some(pos) => format!("{}…", &s[..pos]),
|
||||
None => format!("{}…", &s[..max]),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP-handlers
|
||||
// =============================================================================
|
||||
|
||||
/// GET /pub/{slug} — forside for en publikasjon.
|
||||
pub async fn serve_index(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let collection = find_publishing_collection(&state.db, &slug)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
|
||||
let config = &collection.publishing_config.theme_config;
|
||||
let featured_max = collection.publishing_config.featured_max.unwrap_or(4);
|
||||
let stream_page_size = collection.publishing_config.stream_page_size.unwrap_or(20);
|
||||
|
||||
let (hero, featured, stream) = fetch_index_articles(
|
||||
&state.db,
|
||||
collection.id,
|
||||
featured_max,
|
||||
stream_page_size,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(slug = %slug, error = %e, "Feil ved henting av forsideartikler");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
|
||||
let base_url = collection
|
||||
.publishing_config
|
||||
.custom_domain
|
||||
.as_deref()
|
||||
.map(|d| format!("https://{d}"))
|
||||
.unwrap_or_else(|| format!("/pub/{slug}"));
|
||||
|
||||
let index_data = IndexData {
|
||||
title: collection_title,
|
||||
description: None,
|
||||
hero,
|
||||
featured,
|
||||
stream,
|
||||
};
|
||||
|
||||
let tera = build_tera();
|
||||
let html = render_index(&tera, theme, config, &index_data, &base_url).map_err(|e| {
|
||||
tracing::error!(slug = %slug, theme = %theme, error = %e, "Tera render-feil (index)");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=60")
|
||||
.body(html.into())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// GET /pub/{slug}/{article_id} — enkeltartikkel.
|
||||
pub async fn serve_article(
|
||||
State(state): State<AppState>,
|
||||
Path((slug, article_id)): Path<(String, String)>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let collection = find_publishing_collection(&state.db, &slug)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let theme = collection.publishing_config.theme.as_deref().unwrap_or("blogg");
|
||||
let config = &collection.publishing_config.theme_config;
|
||||
|
||||
let (article, _edge_meta) = fetch_article(&state.db, collection.id, &article_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(slug = %slug, article = %article_id, error = %e, "Feil ved henting av artikkel");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let collection_title = collection.title.unwrap_or_else(|| slug.clone());
|
||||
let base_url = collection
|
||||
.publishing_config
|
||||
.custom_domain
|
||||
.as_deref()
|
||||
.map(|d| format!("https://{d}"))
|
||||
.unwrap_or_else(|| format!("/pub/{slug}"));
|
||||
|
||||
let tera = build_tera();
|
||||
let html = render_article(&tera, theme, config, &article, &collection_title, &base_url)
|
||||
.map_err(|e| {
|
||||
tracing::error!(slug = %slug, article = %article_id, theme = %theme, error = %e, "Tera render-feil (artikkel)");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=300")
|
||||
.body(html.into())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// GET /pub/{slug}/preview/{theme} — forhåndsvisning av tema med testdata.
|
||||
/// Nyttig for å se hvordan et tema ser ut uten reelle data.
|
||||
pub async fn preview_theme(
|
||||
Path((slug, theme)): Path<(String, String)>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let valid_themes = ["avis", "magasin", "blogg", "tidsskrift"];
|
||||
if !valid_themes.contains(&theme.as_str()) {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let config = ThemeConfig::default();
|
||||
|
||||
let sample_articles: Vec<ArticleData> = (1..=6)
|
||||
.map(|i| ArticleData {
|
||||
id: format!("00000000-0000-0000-0000-00000000000{i}"),
|
||||
short_id: format!("0000000{i}"),
|
||||
title: format!("Eksempelartikkel {i}"),
|
||||
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
|
||||
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
|
||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."
|
||||
.to_string(),
|
||||
summary: Some("Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string()),
|
||||
published_at: "2026-03-18T12:00:00Z".to_string(),
|
||||
published_at_short: "18. mars 2026".to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let index_data = IndexData {
|
||||
title: format!("Forhåndsvisning — {theme}"),
|
||||
description: Some("Eksempeldata for temavisning".to_string()),
|
||||
hero: Some(sample_articles[0].clone()),
|
||||
featured: sample_articles[1..4].to_vec(),
|
||||
stream: sample_articles[4..].to_vec(),
|
||||
};
|
||||
|
||||
let base_url = format!("/pub/{slug}");
|
||||
|
||||
let tera = build_tera();
|
||||
let html = render_index(&tera, &theme, &config, &index_data, &base_url).map_err(|e| {
|
||||
tracing::error!(theme = %theme, error = %e, "Tera render-feil (preview)");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.body(html.into())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tester
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn css_variables_use_defaults() {
|
||||
let config = ThemeConfig::default();
|
||||
let css = build_css_variables("avis", &config);
|
||||
assert!(css.contains("--color-primary: #1a1a2e"));
|
||||
assert!(css.contains("--font-heading: 'Georgia'"));
|
||||
assert!(css.contains("--layout-max-width: 1200px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_variables_override() {
|
||||
let config = ThemeConfig {
|
||||
colors: ColorConfig {
|
||||
primary: Some("#ff0000".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let css = build_css_variables("blogg", &config);
|
||||
assert!(css.contains("--color-primary: #ff0000"));
|
||||
// Andre verdier skal fortsatt bruke blogg-defaults
|
||||
assert!(css.contains("--color-accent: #3498db"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tera_builds_successfully() {
|
||||
let tera = build_tera();
|
||||
// Alle 8 tema-templates + base skal finnes
|
||||
let templates: Vec<&str> = tera.get_template_names().collect();
|
||||
assert!(templates.contains(&"base.html"));
|
||||
assert!(templates.contains(&"avis/article.html"));
|
||||
assert!(templates.contains(&"avis/index.html"));
|
||||
assert!(templates.contains(&"magasin/article.html"));
|
||||
assert!(templates.contains(&"magasin/index.html"));
|
||||
assert!(templates.contains(&"blogg/article.html"));
|
||||
assert!(templates.contains(&"blogg/index.html"));
|
||||
assert!(templates.contains(&"tidsskrift/article.html"));
|
||||
assert!(templates.contains(&"tidsskrift/index.html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_article_all_themes() {
|
||||
let tera = build_tera();
|
||||
let config = ThemeConfig::default();
|
||||
let article = ArticleData {
|
||||
id: "test-id".to_string(),
|
||||
short_id: "test-sho".to_string(),
|
||||
title: "Testittel".to_string(),
|
||||
content: "<p>Testinnhold</p>".to_string(),
|
||||
summary: Some("Kort oppsummering".to_string()),
|
||||
published_at: "2026-03-18T12:00:00Z".to_string(),
|
||||
published_at_short: "18. mars 2026".to_string(),
|
||||
};
|
||||
|
||||
for theme in &["avis", "magasin", "blogg", "tidsskrift"] {
|
||||
let html = render_article(&tera, theme, &config, &article, "Testsamling", "/pub/test")
|
||||
.unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}"));
|
||||
assert!(html.contains("Testittel"), "Tittel mangler i {theme}");
|
||||
assert!(html.contains("Testinnhold"), "Innhold mangler i {theme}");
|
||||
assert!(html.contains("--color-primary"), "CSS-variabler mangler i {theme}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_index_all_themes() {
|
||||
let tera = build_tera();
|
||||
let config = ThemeConfig::default();
|
||||
let index = IndexData {
|
||||
title: "Testforside".to_string(),
|
||||
description: None,
|
||||
hero: None,
|
||||
featured: vec![],
|
||||
stream: vec![ArticleData {
|
||||
id: "s1".to_string(),
|
||||
short_id: "s1000000".to_string(),
|
||||
title: "Strøm-artikkel".to_string(),
|
||||
content: "Innhold".to_string(),
|
||||
summary: Some("Sammendrag".to_string()),
|
||||
published_at: "2026-03-18T12:00:00Z".to_string(),
|
||||
published_at_short: "18. mars 2026".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
for theme in &["avis", "magasin", "blogg", "tidsskrift"] {
|
||||
let html = render_index(&tera, theme, &config, &index, "/pub/test")
|
||||
.unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}"));
|
||||
assert!(html.contains("Testforside"), "Tittel mangler i {theme}");
|
||||
assert!(html.contains("Strøm-artikkel"), "Strøm-artikkel mangler i {theme}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_theme_falls_back_to_blogg() {
|
||||
let config = ThemeConfig::default();
|
||||
let css = build_css_variables("nonexistent", &config);
|
||||
let css_blogg = build_css_variables("blogg", &config);
|
||||
assert_eq!(css, css_blogg);
|
||||
}
|
||||
}
|
||||
66
maskinrommet/src/templates/avis/article.html
Normal file
66
maskinrommet/src/templates/avis/article.html
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.article {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 2rem;
|
||||
}
|
||||
.article__main { min-width: 0; }
|
||||
.article__sidebar {
|
||||
border-left: 2px solid var(--color-accent);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.article__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.article__meta {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-muted);
|
||||
}
|
||||
.article__content {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.article__content p { margin-bottom: 1em; }
|
||||
.article__back {
|
||||
display: inline-block;
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.article { grid-template-columns: 1fr; }
|
||||
.article__sidebar {
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--color-accent);
|
||||
padding-left: 0;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="article">
|
||||
<div class="article__main">
|
||||
<h1 class="article__title">{{ article.title }}</h1>
|
||||
<div class="article__meta">Publisert {{ article.published_at_short }}</div>
|
||||
<div class="article__content">{{ article.content | safe }}</div>
|
||||
<a class="article__back" href="{{ base_url }}">← Tilbake til forsiden</a>
|
||||
</div>
|
||||
<aside class="article__sidebar">
|
||||
</aside>
|
||||
</article>
|
||||
{% endblock %}
|
||||
159
maskinrommet/src/templates/avis/index.html
Normal file
159
maskinrommet/src/templates/avis/index.html
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ index.title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.avis-layout {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 1.5rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
border-bottom: 3px solid var(--color-primary);
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.hero__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hero__summary {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-muted);
|
||||
max-width: 60ch;
|
||||
}
|
||||
.hero__meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Featured + sidebar grid */
|
||||
.avis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.featured-list { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.featured-item {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.featured-item__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.featured-item__summary {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
border-left: 2px solid var(--color-accent);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.sidebar__heading {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Stream */
|
||||
.stream { border-top: 2px solid var(--color-primary); padding-top: 1rem; }
|
||||
.stream__heading {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.stream-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.stream-item__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.stream-item__meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.avis-grid { grid-template-columns: 1fr; }
|
||||
.sidebar { border-left: none; border-top: 2px solid var(--color-accent); padding-left: 0; padding-top: 1rem; }
|
||||
.hero__title { font-size: 1.75rem; }
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="avis-layout">
|
||||
{% if index.hero %}
|
||||
<div class="hero">
|
||||
<h2 class="hero__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||
{% if index.hero.summary %}
|
||||
<p class="hero__summary">{{ index.hero.summary }}</p>
|
||||
{% endif %}
|
||||
<div class="hero__meta">{{ index.hero.published_at_short }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if index.featured | length > 0 or index.stream | length > 0 %}
|
||||
<div class="avis-grid">
|
||||
<div class="featured-list">
|
||||
{% for item in index.featured %}
|
||||
<div class="featured-item">
|
||||
<h3 class="featured-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||
{% if item.summary %}
|
||||
<p class="featured-item__summary">{{ item.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar__heading">Siste nytt</div>
|
||||
{% for item in index.stream %}
|
||||
{% if loop.index <= 5 %}
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<a href="{{ base_url }}/{{ item.short_id }}" style="font-size: 0.9rem;">{{ item.title }}</a>
|
||||
<div style="font-size: 0.75rem; color: var(--color-muted);">{{ item.published_at_short }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if index.stream | length > 5 %}
|
||||
<section class="stream">
|
||||
<div class="stream__heading">Flere saker</div>
|
||||
<div class="stream-grid">
|
||||
{% for item in index.stream %}
|
||||
{% if loop.index > 5 %}
|
||||
<div class="stream-item">
|
||||
<h4 class="stream-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h4>
|
||||
<div class="stream-item__meta">{{ item.published_at_short }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
90
maskinrommet/src/templates/base.html
Normal file
90
maskinrommet/src/templates/base.html
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="no">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ collection_title | default(value="Synops") }}{% endblock %}</title>
|
||||
<style>
|
||||
{{ css_variables | safe }}
|
||||
|
||||
/* Reset */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--color-muted);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.site-header__inner {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.site-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.site-title a { color: inherit; }
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--color-muted);
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<div class="site-title">
|
||||
{% if logo_hash %}
|
||||
<a href="{{ base_url }}"><img src="/cas/{{ logo_hash }}" alt="{{ index.title | default(value=collection_title) }}" style="max-height: 2.5rem;"></a>
|
||||
{% else %}
|
||||
<a href="{{ base_url }}">{{ index.title | default(value=collection_title) | default(value="Synops") }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<nav>
|
||||
<a href="{{ base_url }}/feed.xml" title="RSS-feed">RSS</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
Drevet av Synops
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
44
maskinrommet/src/templates/blogg/article.html
Normal file
44
maskinrommet/src/templates/blogg/article.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.blog-article {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.blog-article__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.blog-article__meta {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.blog-article__content {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.blog-article__content p { margin-bottom: 1em; }
|
||||
.blog-article__back {
|
||||
display: inline-block;
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="blog-article">
|
||||
<h1 class="blog-article__title">{{ article.title }}</h1>
|
||||
<div class="blog-article__meta">{{ article.published_at_short }}</div>
|
||||
<div class="blog-article__content">
|
||||
{{ article.content | safe }}
|
||||
</div>
|
||||
<a class="blog-article__back" href="{{ base_url }}">← Tilbake</a>
|
||||
</article>
|
||||
{% endblock %}
|
||||
93
maskinrommet/src/templates/blogg/index.html
Normal file
93
maskinrommet/src/templates/blogg/index.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ index.title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.blog-layout {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.blog-list { list-style: none; }
|
||||
.blog-item {
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.blog-item:first-child { padding-top: 0; }
|
||||
.blog-item__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.blog-item__meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.blog-item__summary {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Pinned hero-artikkel */
|
||||
.blog-pinned {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
.blog-pinned__label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-item__title { font-size: 1.25rem; }
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="blog-layout">
|
||||
{% if index.hero %}
|
||||
<div class="blog-pinned">
|
||||
<div class="blog-pinned__label">Fremhevet</div>
|
||||
<h2 class="blog-item__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||
{% if index.hero.summary %}
|
||||
<p class="blog-item__summary">{{ index.hero.summary }}</p>
|
||||
{% endif %}
|
||||
<div class="blog-item__meta">{{ index.hero.published_at_short }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if index.featured | length > 0 %}
|
||||
{% for item in index.featured %}
|
||||
<div class="blog-item" style="border-left: 3px solid var(--color-accent); padding-left: 1rem; margin-bottom: 1rem;">
|
||||
<h3 class="blog-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||
{% if item.summary %}
|
||||
<p class="blog-item__summary">{{ item.summary }}</p>
|
||||
{% endif %}
|
||||
<div class="blog-item__meta">{{ item.published_at_short }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<ul class="blog-list">
|
||||
{% for item in index.stream %}
|
||||
<li class="blog-item">
|
||||
<h3 class="blog-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||
<div class="blog-item__meta">{{ item.published_at_short }}</div>
|
||||
{% if item.summary %}
|
||||
<p class="blog-item__summary">{{ item.summary }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
59
maskinrommet/src/templates/magasin/article.html
Normal file
59
maskinrommet/src/templates/magasin/article.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.mag-article {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.mag-article__header {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mag-article__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.mag-article__meta {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.mag-article__content {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
.mag-article__content p { margin-bottom: 1.25em; }
|
||||
.mag-article__back {
|
||||
display: inline-block;
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mag-article__title { font-size: 2rem; }
|
||||
.mag-article__header { padding: 2rem 0 1.5rem; }
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="mag-article">
|
||||
<header class="mag-article__header">
|
||||
<h1 class="mag-article__title">{{ article.title }}</h1>
|
||||
<div class="mag-article__meta">Publisert {{ article.published_at_short }}</div>
|
||||
</header>
|
||||
<div class="mag-article__content">
|
||||
{{ article.content | safe }}
|
||||
<a class="mag-article__back" href="{{ base_url }}">← Tilbake</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
132
maskinrommet/src/templates/magasin/index.html
Normal file
132
maskinrommet/src/templates/magasin/index.html
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ index.title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.mag-layout {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Hero — fullbredde */
|
||||
.mag-hero {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.mag-hero__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.mag-hero__summary {
|
||||
font-size: 1.15rem;
|
||||
color: var(--color-muted);
|
||||
max-width: 50ch;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mag-hero__meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Featured cards */
|
||||
.mag-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.mag-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.mag-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
.mag-card__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.35rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.mag-card__summary {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mag-card__meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Kronologisk strøm */
|
||||
.mag-stream { border-top: 2px solid var(--color-primary); padding-top: 1.5rem; }
|
||||
.mag-stream__heading {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mag-stream-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.mag-stream-item__title { font-size: 1rem; }
|
||||
.mag-stream-item__date { font-size: 0.8rem; color: var(--color-muted); white-space: nowrap; margin-left: 1rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mag-hero__title { font-size: 2rem; }
|
||||
.mag-hero { padding: 2rem 0; }
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mag-layout">
|
||||
{% if index.hero %}
|
||||
<div class="mag-hero">
|
||||
<h2 class="mag-hero__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||
{% if index.hero.summary %}
|
||||
<p class="mag-hero__summary">{{ index.hero.summary }}</p>
|
||||
{% endif %}
|
||||
<div class="mag-hero__meta">{{ index.hero.published_at_short }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if index.featured | length > 0 %}
|
||||
<div class="mag-cards">
|
||||
{% for item in index.featured %}
|
||||
<div class="mag-card">
|
||||
<h3 class="mag-card__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||
{% if item.summary %}
|
||||
<p class="mag-card__summary">{{ item.summary }}</p>
|
||||
{% endif %}
|
||||
<div class="mag-card__meta">{{ item.published_at_short }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if index.stream | length > 0 %}
|
||||
<section class="mag-stream">
|
||||
<h3 class="mag-stream__heading">Alle artikler</h3>
|
||||
{% for item in index.stream %}
|
||||
<div class="mag-stream-item">
|
||||
<a class="mag-stream-item__title" href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a>
|
||||
<span class="mag-stream-item__date">{{ item.published_at_short }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
maskinrommet/src/templates/tidsskrift/article.html
Normal file
51
maskinrommet/src/templates/tidsskrift/article.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.journal-article {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 3rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.journal-article__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.journal-article__meta {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-muted);
|
||||
}
|
||||
.journal-article__content {
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
}
|
||||
.journal-article__content p { margin-bottom: 1em; text-indent: 1.5em; }
|
||||
.journal-article__content p:first-child { text-indent: 0; }
|
||||
.journal-article__back {
|
||||
display: inline-block;
|
||||
margin-top: 2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="journal-article">
|
||||
<h1 class="journal-article__title">{{ article.title }}</h1>
|
||||
<div class="journal-article__meta">Publisert {{ article.published_at_short }}</div>
|
||||
<div class="journal-article__content">
|
||||
{{ article.content | safe }}
|
||||
</div>
|
||||
<a class="journal-article__back" href="{{ base_url }}">← Tilbake til innholdsfortegnelse</a>
|
||||
</article>
|
||||
{% endblock %}
|
||||
133
maskinrommet/src/templates/tidsskrift/index.html
Normal file
133
maskinrommet/src/templates/tidsskrift/index.html
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ index.title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.journal-layout {
|
||||
max-width: var(--layout-max-width);
|
||||
margin: 3rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.journal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid var(--color-primary);
|
||||
}
|
||||
.journal-header__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.journal-header__desc {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-muted);
|
||||
font-style: italic;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Nummerert innholdsfortegnelse */
|
||||
.journal-toc {
|
||||
counter-reset: article-counter;
|
||||
list-style: none;
|
||||
}
|
||||
.journal-toc__item {
|
||||
counter-increment: article-counter;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
}
|
||||
.journal-toc__item::before {
|
||||
content: counter(article-counter) ".";
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-muted);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
}
|
||||
.journal-toc__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.journal-toc__meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Fremhevet (hero/featured) */
|
||||
.journal-featured {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-primary);
|
||||
}
|
||||
.journal-featured__label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.journal-featured__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.journal-featured__summary {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="journal-layout">
|
||||
<header class="journal-header">
|
||||
<h1 class="journal-header__title">{{ index.title }}</h1>
|
||||
{% if index.description %}
|
||||
<p class="journal-header__desc">{{ index.description }}</p>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if index.hero %}
|
||||
<div class="journal-featured">
|
||||
<div class="journal-featured__label">Hovedartikkel</div>
|
||||
<h2 class="journal-featured__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
|
||||
{% if index.hero.summary %}
|
||||
<p class="journal-featured__summary">{{ index.hero.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if index.featured | length > 0 %}
|
||||
{% for item in index.featured %}
|
||||
<div class="journal-featured" style="border-bottom-color: #eee;">
|
||||
<h3 class="journal-featured__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
|
||||
{% if item.summary %}
|
||||
<p class="journal-featured__summary">{{ item.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if index.stream | length > 0 %}
|
||||
<h2 style="font-family: var(--font-heading); font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted); margin-bottom: 0.5rem;">Innholdsfortegnelse</h2>
|
||||
<ol class="journal-toc">
|
||||
{% for item in index.stream %}
|
||||
<li class="journal-toc__item">
|
||||
<div>
|
||||
<div class="journal-toc__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></div>
|
||||
<div class="journal-toc__meta">{{ item.published_at_short }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
3
tasks.md
3
tasks.md
|
|
@ -137,8 +137,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
|
||||
## Fase 14: Publisering
|
||||
|
||||
- [~] 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".
|
||||
> Påbegynt: 2026-03-18T00:33
|
||||
- [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).
|
||||
- [ ] 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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue