RSS/Atom-feed (oppgave 14.8): feed-discovery + betinget RSS-lenke

Feeden i rss.rs var allerede implementert med full RSS 2.0 og Atom 1.0
støtte, inkl. podcast-enclosures. Det som manglet var integrasjon med
publiseringstemplates:

- base.html: <link rel="alternate"> for feed auto-discovery (kun når
  samlingen har rss-trait)
- base.html: RSS-lenke i nav vises kun for samlinger med rss-trait
- publishing.rs: has_rss propageres fra CollectionRow gjennom alle
  render-funksjoner til Tera-kontekst
- CAS-rendering (render_article_to_cas, render_index_to_cas) sjekker
  også rss-trait for korrekt template-kontekst

Verifisert: kompilerer, alle 36 tester passerer, feed.xml returnerer
gyldig RSS/Atom, 404 for ukjente slugs, discovery-link i HTML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 01:42:16 +00:00
parent 71f7264100
commit 265adea0b3
3 changed files with 35 additions and 18 deletions

View file

@ -329,6 +329,7 @@ pub fn render_article(
collection_title: &str, collection_title: &str,
base_url: &str, base_url: &str,
seo: &SeoData, seo: &SeoData,
has_rss: bool,
) -> 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");
@ -341,6 +342,7 @@ pub fn render_article(
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); ctx.insert("seo", seo);
ctx.insert("has_rss", &has_rss);
tera.render(&template_name, &ctx) tera.render(&template_name, &ctx)
} }
@ -352,6 +354,7 @@ pub fn render_index(
config: &ThemeConfig, config: &ThemeConfig,
index: &IndexData, index: &IndexData,
base_url: &str, base_url: &str,
has_rss: bool,
) -> 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}/index.html"); let template_name = format!("{theme}/index.html");
@ -362,6 +365,7 @@ pub fn render_index(
ctx.insert("index", index); ctx.insert("index", index);
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("has_rss", &has_rss);
tera.render(&template_name, &ctx) tera.render(&template_name, &ctx)
} }
@ -405,13 +409,16 @@ pub async fn render_article_to_cas(
return Err(format!("Samling {collection_id} finnes ikke")); return Err(format!("Samling {collection_id} finnes ikke"));
}; };
let publishing_config: PublishingConfig = collection_metadata let coll_traits = collection_metadata.get("traits");
.get("traits")
let publishing_config: PublishingConfig = coll_traits
.and_then(|t| t.get("publishing")) .and_then(|t| t.get("publishing"))
.cloned() .cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default()) .map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default(); .unwrap_or_default();
let has_rss = coll_traits.and_then(|t| t.get("rss")).is_some();
let slug = publishing_config.slug.as_deref().unwrap_or("unknown"); let slug = publishing_config.slug.as_deref().unwrap_or("unknown");
let theme = publishing_config.theme.as_deref().unwrap_or("blogg"); let theme = publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &publishing_config.theme_config; let config = &publishing_config.theme_config;
@ -496,7 +503,7 @@ pub async fn render_article_to_cas(
let seo = build_seo_data(&article_data, &collection_title, &canonical_url); let seo = build_seo_data(&article_data, &collection_title, &canonical_url);
let tera = build_tera(); let tera = build_tera();
let html = render_article(&tera, theme, config, &article_data, &collection_title, &base_url, &seo) let html = render_article(&tera, theme, config, &article_data, &collection_title, &base_url, &seo, has_rss)
.map_err(|e| format!("Tera render-feil: {e}"))?; .map_err(|e| format!("Tera render-feil: {e}"))?;
// 5. Lagre i CAS // 5. Lagre i CAS
@ -586,13 +593,16 @@ pub async fn render_index_to_cas(
return Err(format!("Samling {collection_id} finnes ikke")); return Err(format!("Samling {collection_id} finnes ikke"));
}; };
let publishing_config: PublishingConfig = collection_metadata let idx_traits = collection_metadata.get("traits");
.get("traits")
let publishing_config: PublishingConfig = idx_traits
.and_then(|t| t.get("publishing")) .and_then(|t| t.get("publishing"))
.cloned() .cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default()) .map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default(); .unwrap_or_default();
let has_rss = idx_traits.and_then(|t| t.get("rss")).is_some();
let slug = publishing_config.slug.as_deref().unwrap_or("unknown"); let slug = publishing_config.slug.as_deref().unwrap_or("unknown");
let theme = publishing_config.theme.as_deref().unwrap_or("blogg"); let theme = publishing_config.theme.as_deref().unwrap_or("blogg");
let config = &publishing_config.theme_config; let config = &publishing_config.theme_config;
@ -621,7 +631,7 @@ pub async fn render_index_to_cas(
// Render med Tera // Render med Tera
let tera = build_tera(); let tera = build_tera();
let html = render_index(&tera, theme, config, &index_data, &base_url) let html = render_index(&tera, theme, config, &index_data, &base_url, has_rss)
.map_err(|e| format!("Tera render-feil (index): {e}"))?; .map_err(|e| format!("Tera render-feil (index): {e}"))?;
// Lagre i CAS // Lagre i CAS
@ -691,6 +701,7 @@ struct CollectionRow {
id: Uuid, id: Uuid,
title: Option<String>, title: Option<String>,
publishing_config: PublishingConfig, publishing_config: PublishingConfig,
has_rss: bool,
} }
/// Finn samling med publishing-trait basert på slug. /// Finn samling med publishing-trait basert på slug.
@ -715,17 +726,23 @@ async fn find_publishing_collection(
return Ok(None); return Ok(None);
}; };
let publishing_config: PublishingConfig = metadata let traits = metadata.get("traits");
.get("traits")
let publishing_config: PublishingConfig = traits
.and_then(|t| t.get("publishing")) .and_then(|t| t.get("publishing"))
.cloned() .cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default()) .map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default(); .unwrap_or_default();
let has_rss = traits
.and_then(|t| t.get("rss"))
.is_some();
Ok(Some(CollectionRow { Ok(Some(CollectionRow {
id, id,
title, title,
publishing_config, publishing_config,
has_rss,
})) }))
} }
@ -1093,7 +1110,7 @@ pub async fn serve_index(
}; };
let tera = build_tera(); let tera = build_tera();
let html = render_index(&tera, theme, &config, &index_data, &base_url).map_err(|e| { let html = render_index(&tera, theme, &config, &index_data, &base_url, collection.has_rss).map_err(|e| {
tracing::error!(slug = %slug, theme = %theme, error = %e, "Tera render-feil (index)"); tracing::error!(slug = %slug, theme = %theme, error = %e, "Tera render-feil (index)");
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
@ -1178,7 +1195,7 @@ pub async fn serve_article(
let seo = build_seo_data(&fetched.article, &collection_title, &canonical_url); 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, &fetched.article, &collection_title, &base_url, &seo) let html = render_article(&tera, theme, config, &fetched.article, &collection_title, &base_url, &seo, collection.has_rss)
.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
@ -1229,7 +1246,7 @@ pub async fn preview_theme(
let base_url = format!("/pub/{slug}"); let base_url = format!("/pub/{slug}");
let tera = build_tera(); let tera = build_tera();
let html = render_index(&tera, &theme, &config, &index_data, &base_url).map_err(|e| { let html = render_index(&tera, &theme, &config, &index_data, &base_url, false).map_err(|e| {
tracing::error!(theme = %theme, error = %e, "Tera render-feil (preview)"); tracing::error!(theme = %theme, error = %e, "Tera render-feil (preview)");
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
@ -1316,7 +1333,7 @@ mod tests {
let seo = default_seo(); 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", &seo) let html = render_article(&tera, theme, &config, &article, "Testsamling", "/pub/test", &seo, false)
.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}");
@ -1346,7 +1363,7 @@ mod tests {
json_ld: r#"{"@type":"Article"}"#.to_string(), json_ld: r#"{"@type":"Article"}"#.to_string(),
}; };
let html = render_article(&tera, "blogg", &config, &article, "Testpub", "/pub/test", &seo) let html = render_article(&tera, "blogg", &config, &article, "Testpub", "/pub/test", &seo, false)
.expect("Render feilet"); .expect("Render feilet");
assert!(html.contains("og:title"), "OG-tittel mangler"); assert!(html.contains("og:title"), "OG-tittel mangler");
@ -1377,7 +1394,7 @@ mod tests {
}; };
for theme in &["avis", "magasin", "blogg", "tidsskrift"] { for theme in &["avis", "magasin", "blogg", "tidsskrift"] {
let html = render_index(&tera, theme, &config, &index, "/pub/test") let html = render_index(&tera, theme, &config, &index, "/pub/test", false)
.unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}")); .unwrap_or_else(|e| panic!("Render feilet for {theme}: {e}"));
assert!(html.contains("Testforside"), "Tittel mangler i {theme}"); assert!(html.contains("Testforside"), "Tittel mangler i {theme}");
assert!(html.contains("Strøm-artikkel"), "Strøm-artikkel mangler i {theme}"); assert!(html.contains("Strøm-artikkel"), "Strøm-artikkel mangler i {theme}");

View file

@ -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>
{% if has_rss %}<link rel="alternate" type="application/rss+xml" title="{{ collection_title | default(value='RSS') }}" href="{{ base_url }}/feed.xml">{% endif %}
{% block seo %}{% endblock %} {% block seo %}{% endblock %}
<style> <style>
{{ css_variables | safe }} {{ css_variables | safe }}
@ -72,9 +73,9 @@
<a href="{{ base_url }}">{{ index.title | default(value=collection_title) | default(value="Synops") }}</a> <a href="{{ base_url }}">{{ index.title | default(value=collection_title) | default(value="Synops") }}</a>
{% endif %} {% endif %}
</div> </div>
<nav> {% if has_rss %}<nav>
<a href="{{ base_url }}/feed.xml" title="RSS-feed">RSS</a> <a href="{{ base_url }}/feed.xml" title="RSS-feed">RSS</a>
</nav> </nav>{% endif %}
</div> </div>
</header> </header>

View file

@ -149,8 +149,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 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. - [x] 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.
- [x] 14.6 Forside-admin i frontend: visuell editor for hero/featured/strøm. Drag-and-drop mellom plasser. Pin-knapp. Forhåndsvisning. Oppdaterer edge-metadata via maskinrommet. - [x] 14.6 Forside-admin i frontend: visuell editor for hero/featured/strøm. Drag-and-drop mellom plasser. Pin-knapp. Forhåndsvisning. Oppdaterer edge-metadata via maskinrommet.
- [x] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge. - [x] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge.
- [~] 14.8 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. Maks `rss_max_items` (default 50). - [x] 14.8 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. Maks `rss_max_items` (default 50).
> Påbegynt: 2026-03-18T01:34
- [ ] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL. - [ ] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL.
- [ ] 14.10 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending". - [ ] 14.10 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending".
- [ ] 14.11 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata. - [ ] 14.11 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata.