From 01504fb1202e9499c2a41ddc80db05f8bb483eeb Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 1 Sep 2026 22:12:20 +0200 Subject: [PATCH] Per-site favicon via site.yaml A content repo can ship a favicon (svg/png/ico) named in site.yaml; portal serves it at /site/ and injects a after the static defaults so it wins. Also serves .ico assets. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ --- Cargo.lock | 2 +- src/app.rs | 11 ++++++++++- src/content.rs | 13 +++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a34f9c..0b0eed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2948,7 +2948,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.3.30" +version = "0.3.31" dependencies = [ "anyhow", "arc-swap", diff --git a/src/app.rs b/src/app.rs index a7f247c..efdb7c6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,5 @@ use leptos::prelude::*; -use leptos_meta::{provide_meta_context, HashedStylesheet, Html, MetaTags, Stylesheet, Title}; +use leptos_meta::{provide_meta_context, HashedStylesheet, Html, Link, MetaTags, Stylesheet, Title}; use leptos_router::{ components::{Route, Router, Routes}, hooks::{use_location, use_navigate, use_query_map}, @@ -89,10 +89,19 @@ pub fn App() -> impl IntoView { .and_then(|s| s.stylesheet) .map(|p| format!("/site/{p}")) }); + // A content-shipped favicon overrides the built-in one; injected + // into the head after the static defaults, so it wins. + let favicon = Memo::new(move |_| { + site.get() + .and_then(|r| r.ok()) + .and_then(|s| s.favicon) + .map(|p| format!("/site/{p}")) + }); view! { {move || custom_css.get().map(|href| view! { })} + {move || favicon.get().map(|href| view! { })} {move || { site.get() diff --git a/src/content.rs b/src/content.rs index 25485fb..dcecfb5 100644 --- a/src/content.rs +++ b/src/content.rs @@ -611,6 +611,10 @@ pub struct SiteConfig { /// same-origin at `/site/`; plain path only. #[serde(default)] pub stylesheet: Option, + /// A content-repo-relative favicon (svg/png/ico), served at + /// `/site/` and used in place of the built-in one. + #[serde(default)] + pub favicon: Option, #[serde(default)] pub hero: HeroConfig, } @@ -673,6 +677,14 @@ impl SiteConfig { ); } } + if let Some(icon) = &self.favicon { + let ok = [".svg", ".png", ".ico"].iter().any(|e| icon.ends_with(e)); + if !is_safe_site_path(icon) || !ok { + anyhow::bail!( + "site.yaml: favicon {icon:?} must be a plain repo-relative .svg/.png/.ico path" + ); + } + } Ok(()) } } @@ -1537,6 +1549,7 @@ pub async fn site_asset_handler( Some("png") => "image/png", Some("webp") => "image/webp", Some("avif") => "image/avif", + Some("ico") => "image/x-icon", Some("woff2") => "font/woff2", _ => "application/octet-stream", };