Hero becomes a content-owned module; site asset proxy; gesture growth fix
Test / test (push) Successful in 24s

site.yaml's hero is now plain | module, where module names a
JavaScript file the content repo ships (mount(container) -> handle
with stop()). Portal serves content assets same-origin at
/site/<path> (Gitea raw sends no CORS headers), starts the module at
HTML parse time, adopts it on hydration, mounts fresh via the inline
script's __mountHero on client-side navigation, and stops it on
leave. The YES canvas (yes.js) and the gesture hero mode leave the
engine - uhhm/questions ships YES as its hero.js, redoal/questions
ships a sine-swings band. Gesture form canvas no longer balloons after
a stroke: the wrap's aspect-ratio reservation is scoped to :empty
(pre-mount) so it can't turn echo-strip height into width inside the
flex field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-25 17:26:29 +02:00
co-authored by Claude Fable 5
parent 307fd7e753
commit 1afd34c22e
8 changed files with 230 additions and 884 deletions
+118 -29
View File
@@ -347,51 +347,76 @@ pub struct SiteConfig {
pub hero: HeroConfig,
}
/// What the landing page's hero is: the YES canvas piece (`yes`, the
/// default), a redoal gesture-drawing canvas (`gesture`), or copy
/// only (`plain`).
/// What the landing page's hero is. `plain` is just the copy; `module`
/// hands the hero to a JavaScript module the content repo itself
/// ships (`module: hero.js`, a repo-relative path portal serves
/// same-origin at `/site/<path>`, since Gitea's raw endpoint sends no
/// CORS headers). The module exports `mount(container) -> handle` and
/// the handle has `stop()`; portal starts it at HTML parse time and
/// adopts it on hydration. The piece's look - canvases, SVG, CSS - is
/// entirely the module's: portal only reserves the box.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HeroConfig {
#[serde(default = "default_hero_kind")]
pub kind: String,
/// `kind: gesture` only - ws(s):// URL of a redoal-relay for
/// ambient echoes. Absent means the hero draws offline.
#[serde(default)]
pub relay: Option<String>,
pub module: Option<String>,
}
impl Default for HeroConfig {
fn default() -> Self {
Self { kind: default_hero_kind(), relay: None }
Self { kind: default_hero_kind(), module: None }
}
}
fn default_hero_kind() -> String {
"yes".to_string()
"plain".to_string()
}
#[cfg(feature = "ssr")]
impl SiteConfig {
/// Shared by `load_site_from_gitea` and the `question-lint` binary
/// so a typo'd hero kind is a caught rejection, not a silently
/// plain hero.
/// Same policy as `validate_questions`: content declares, the
/// runtime refuses what it doesn't understand at load time, so a
/// typo'd hero kind is a caught rejection, not a silently plain
/// hero.
pub fn validate(&self) -> anyhow::Result<()> {
if !matches!(self.hero.kind.as_str(), "yes" | "gesture" | "plain") {
anyhow::bail!(
"site.yaml: hero.kind {:?} is not one of yes | gesture | plain",
self.hero.kind
);
}
if let Some(relay) = &self.hero.relay {
if self.hero.kind != "gesture" {
anyhow::bail!("site.yaml: hero.relay only makes sense with hero.kind: gesture");
match self.hero.kind.as_str() {
"plain" => {
if self.hero.module.is_some() {
anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module");
}
}
validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?;
"module" => {
let Some(module) = &self.hero.module else {
anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)");
};
if !is_safe_site_path(module) {
anyhow::bail!(
"site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)"
);
}
}
other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"),
}
Ok(())
}
}
/// A repo-relative asset path portal is willing to proxy from the
/// content repo: plain segments only - no traversal, no leading slash,
/// no scheme - so `/site/<path>` can only ever reach the content repo.
pub fn is_safe_site_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& path.split('/').all(|seg| {
!seg.is_empty()
&& seg != ".."
&& seg
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
})
}
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr")]
@@ -428,6 +453,15 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
/// Extracts `scheme://host` from a repo's normal browser URL - the
/// Gitea API base every helper in this module builds requests against.
#[cfg(feature = "ssr")]
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
/// `site_asset_handler` proxies content-shipped files from.
#[cfg(feature = "ssr")]
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
let (owner, repo) = parse_owner_repo(repo_url)?;
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
}
#[cfg(feature = "ssr")]
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
let parsed = url::Url::parse(repo_url)
@@ -1091,6 +1125,54 @@ pub struct GiteaRepoQuery {
pub repo: String,
}
/// `GET /site/{*path}` - serves a file from the content repo's default
/// branch same-origin, so content-shipped assets (the hero module,
/// its stylesheet) are importable by the page: Gitea's raw endpoint
/// sends no CORS headers, and an ES module import across origins is
/// refused without them. Path is validated to plain segments
/// (`is_safe_site_path`), the response revalidates like /pkg
/// (Cache-Control: no-cache) so a content push shows up on reload.
#[cfg(feature = "ssr")]
pub async fn site_asset_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
if !is_safe_site_path(&path) {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
let client = openidconnect::reqwest::Client::new();
let resp = match client.get(&url).send().await.and_then(|r| r.error_for_status()) {
Ok(r) => r,
Err(e) => {
tracing::warn!("site asset {path}: {e}");
return (StatusCode::NOT_FOUND, "no such site asset").into_response();
}
};
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
};
let mime = match path.rsplit('.').next() {
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("json") => "application/json",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
};
(
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")],
bytes,
)
.into_response()
}
/// Looks up `owner/repo` on the same Gitea instance content is loaded
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
@@ -1386,9 +1468,9 @@ alternatives:
}
#[test]
fn site_config_defaults_to_the_yes_hero() {
fn site_config_defaults_to_the_plain_hero() {
let site = SiteConfig::default();
assert_eq!(site.hero.kind, "yes");
assert_eq!(site.hero.kind, "plain");
assert!(site.validate().is_ok());
// And an empty file parses to the same thing.
let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap();
@@ -1403,21 +1485,28 @@ alternatives:
}
#[test]
fn site_config_rejects_relay_without_gesture_hero() {
let site: SiteConfig =
serde_yaml::from_str("hero: { kind: yes, relay: \"wss://relay.redoal.com\" }").unwrap();
assert!(site.validate().is_err());
fn site_config_module_hero_needs_a_safe_path() {
let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap();
assert!(missing.validate().is_err());
let traversal: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap();
assert!(traversal.validate().is_err());
let absolute: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
assert!(absolute.validate().is_err());
let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
assert!(ok.validate().is_ok());
}
#[test]
fn redoal_site_yaml_shape_parses() {
let site: SiteConfig = serde_yaml::from_str(
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: gesture\n relay: wss://relay.redoal.com\n",
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n",
)
.unwrap();
assert!(site.validate().is_ok());
assert_eq!(site.title.as_deref(), Some("redoal"));
assert_eq!(site.hero.kind, "gesture");
assert_eq!(site.hero.module.as_deref(), Some("hero.js"));
}
#[test]