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/<path> and injects a <link rel=icon> after
the static defaults so it wins. Also serves .ico assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-01 22:12:20 +02:00
co-authored by Claude Fable 5
parent b25a5839b0
commit 01504fb120
3 changed files with 24 additions and 2 deletions
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.3.30" version = "0.3.31"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+10 -1
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*; 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::{ use leptos_router::{
components::{Route, Router, Routes}, components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map}, hooks::{use_location, use_navigate, use_query_map},
@@ -89,10 +89,19 @@ pub fn App() -> impl IntoView {
.and_then(|s| s.stylesheet) .and_then(|s| s.stylesheet)
.map(|p| format!("/site/{p}")) .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! { view! {
<Html attr:lang=move || lang.get()/> <Html attr:lang=move || lang.get()/>
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })} {move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
{move || favicon.get().map(|href| view! { <Link rel="icon" href=href/> })}
<Suspense fallback=|| ()> <Suspense fallback=|| ()>
{move || { {move || {
site.get() site.get()
+13
View File
@@ -611,6 +611,10 @@ pub struct SiteConfig {
/// same-origin at `/site/<path>`; plain path only. /// same-origin at `/site/<path>`; plain path only.
#[serde(default)] #[serde(default)]
pub stylesheet: Option<String>, pub stylesheet: Option<String>,
/// A content-repo-relative favicon (svg/png/ico), served at
/// `/site/<path>` and used in place of the built-in one.
#[serde(default)]
pub favicon: Option<String>,
#[serde(default)] #[serde(default)]
pub hero: HeroConfig, 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(()) Ok(())
} }
} }
@@ -1537,6 +1549,7 @@ pub async fn site_asset_handler(
Some("png") => "image/png", Some("png") => "image/png",
Some("webp") => "image/webp", Some("webp") => "image/webp",
Some("avif") => "image/avif", Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2", Some("woff2") => "font/woff2",
_ => "application/octet-stream", _ => "application/octet-stream",
}; };