From 452ea88fbfa0c8f9ae748c94a134c10a7ed8b26c Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 24 Aug 2026 22:30:28 +0200 Subject: [PATCH] Filesystem routes, sections, dynamic segments; instant YES hero The questions/ tree is the router now: ids derive from file paths (index.yaml names its directory; explicit id still wins for legacy content), actions and requires_chain accept relative refs, nested non-index files infer followup, and _section.yaml applies qualifies/ requires_chain/responsible to everything under its directory. Dynamic [name].yaml pages serve any /dir/ with the segment substituted into {name} resource-key placeholders; submissions index their chain node in a portal_chains KV so requires_chain pages can verify a visitor's ?chain= lineage actually ends at the required question. Loading uses one recursive git-trees call; question_lint walks subdirectories the same way. Implements docs/design/filesystem-routes.md. Also: the YES hero now starts at HTML parse time via an inline module script (yes.js moved to public/ for a stable /yes.js the wasm binding raw_module-imports too - snippet paths are per-build-hashed), with hydration adopting the running instance; and both gesture containers reserve their box in CSS so mounting doesn't shift content. Co-Authored-By: Claude Fable 5 --- yes.js => public/yes.js | 0 src/app.rs | 149 ++++++++++- src/bin/question_lint.rs | 39 ++- src/chain.rs | 54 ++++ src/content.rs | 522 ++++++++++++++++++++++++++++++++++++--- src/main.rs | 5 + src/resource.rs | 11 +- style/main.css | 10 + 8 files changed, 721 insertions(+), 69 deletions(-) rename yes.js => public/yes.js (100%) diff --git a/yes.js b/public/yes.js similarity index 100% rename from yes.js rename to public/yes.js diff --git a/src/app.rs b/src/app.rs index 15da04f..1fc9633 100644 --- a/src/app.rs +++ b/src/app.rs @@ -81,7 +81,10 @@ fn QuestionPage() -> impl IntoView { let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain"))); let query_email = Memo::new(move |_| query.with(|q| q.get("email"))); - let question = Resource::new(move || path.get(), get_question); + let question = Resource::new( + move || (path.get(), parent_hash.get()), + |(path, chain)| get_question(path, chain), + ); let user = Resource::new(|| (), |_| current_user()); let site = Resource::new(|| (), |_| get_site()); @@ -103,7 +106,7 @@ fn QuestionPage() -> impl IntoView { let site_cfg = site.get().and_then(|r| r.ok()).unwrap_or_default(); question_res .map(|res| match res { - Ok(Some(q)) => { + Ok(Some(page)) => { let current = user_res.and_then(|r| r.ok()).flatten(); view! { // A second outranks App's @@ -114,7 +117,8 @@ fn QuestionPage() -> impl IntoView { .clone() .map(|t| view! { <Title text=t/> })} <QuestionView - question=q + question=page.question + chain_gate=page.chain_gate parent_hash=parent_hash.get() query_email=query_email.get() user=current @@ -133,6 +137,7 @@ fn QuestionPage() -> impl IntoView { #[component] fn QuestionView( question: Question, + chain_gate: Option<(String, String)>, parent_hash: Option<String>, query_email: Option<String>, user: Option<User>, @@ -140,6 +145,35 @@ fn QuestionView( ) -> impl IntoView { let question_id = question.id.clone(); + // The provenance counterpart to the qualifies gate below: a + // requires_chain page whose visitor holds no verifiable lineage to + // the required question renders a pointer there instead of its + // alternatives. + if let Some((target, target_name)) = chain_gate { + let label = if target_name.is_empty() { + target.clone() + } else { + target_name + }; + return view! { + <Hero + title=question.name.clone() + description=question.description.clone() + landing=question_id == "/" + site=site.clone() + /> + <div class="alternatives"> + <section class="alt-card gate-card"> + <p>"This page follows from an answer you don't seem to carry yet."</p> + <a class="alt-submit" href=target> + {label} + </a> + </section> + </div> + } + .into_any(); + } + // Generic: any question with `qualifies` set renders this same gate // instead of its alternatives - "/review" isn't a special case, it's // just a question that happens to have `qualifies` set. @@ -322,7 +356,12 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView { mod yes { use wasm_bindgen::prelude::*; - #[wasm_bindgen(module = "/yes.js")] + // raw_module (a runtime URL, not a bundled snippet) on purpose: + // the file lives in public/ so it's served at the stable /yes.js + // - the same URL the hero's inline early-mount script imports. + // A bundled snippet would sit under a per-build hashed + // /pkg/snippets/ path the inline script couldn't know. + #[wasm_bindgen(raw_module = "/yes.js")] extern "C" { #[wasm_bindgen(js_name = RasterizedYES)] pub type RasterizedYes; @@ -434,7 +473,28 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> if raster_ref.get().is_none() { return; } - instance.set_value(Some(yes::RasterizedYes::new())); + if instance.with_value(|i| i.is_some()) { + return; + } + // Adopt the hero's inline early-mount instance (started at + // HTML parse time, long before this wasm was even fetched) + // instead of starting a second animation. The flag covers + // the opposite ordering too: an inline script that runs + // after this sees it and stays inert. + use wasm_bindgen::JsCast; + let global = js_sys::global(); + let _ = js_sys::Reflect::set(&global, &"__yesAdopted".into(), &true.into()); + let early = js_sys::Reflect::get(&global, &"__yesEarly".into()) + .ok() + .filter(|v| !v.is_undefined() && !v.is_null()); + let inst = match early { + Some(v) => { + let _ = js_sys::Reflect::delete_property(&global, &"__yesEarly".into()); + v.unchecked_into::<yes::RasterizedYes>() + } + None => yes::RasterizedYes::new(), + }; + instance.set_value(Some(inst)); }); on_cleanup(move || { instance.update_value(|opt| { @@ -481,6 +541,15 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> <canvas id="lineCanvas"></canvas> <canvas id="rasterCanvas" node_ref=raster_ref></canvas> </div> + // Starts the animation at HTML parse time + // instead of waiting out the wasm bundle's + // fetch + hydration; the hydrate Effect above + // adopts (never duplicates) the instance, and + // the guards make either execution order safe. + <script + type="module" + inner_html="import('/yes.js').then((m) => { if (!window.__yesAdopted && !window.__yesEarly) window.__yesEarly = new m.RasterizedYES(); });" + ></script> } })} {show_gesture @@ -1640,11 +1709,56 @@ fn NotFound() -> impl IntoView { } } +/// What a URL resolves to: the question (dynamic pages arrive with +/// their segment value already substituted, see +/// `content::resolve_question`) plus whether a `requires_chain` gate +/// blocked it - `(target id, target name)` so the gate can point the +/// visitor at where the required answer comes from. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Page { + pub question: Question, + pub chain_gate: Option<(String, String)>, +} + #[server(endpoint = "get_question")] -pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> { +pub async fn get_question( + path: String, + chain: Option<String>, +) -> Result<Option<Page>, ServerFnError> { + use crate::content::{path_matches, resolve_question}; use crate::server::AppState; let state = expect_context::<AppState>(); - Ok(state.questions.load().get(&path).cloned()) + let questions = state.questions.load(); + let Some(question) = resolve_question(&questions, &path) else { + return Ok(None); + }; + let mut chain_gate = None; + if let Some(target) = &question.requires_chain { + // The claimed lineage must verifiably end at the required + // question - an unindexed or absent hash reads as unverified, + // and the gate stays shut. Dynamic targets match any concrete + // answer of theirs. + let verified = match &chain { + Some(hash) => crate::chain::lookup_node(&state.jetstream, hash) + .await + .is_some_and(|node| { + node.question_id == *target + || path_matches(target, &node.question_id).is_some() + }), + None => false, + }; + if !verified { + let name = questions + .get(target) + .map(|q| q.name.clone()) + .unwrap_or_default(); + chain_gate = Some((target.clone(), name)); + } + } + Ok(Some(Page { + question, + chain_gate, + })) } /// The content repo's branding (`site.yaml`) - title, wordmark, hero @@ -1681,7 +1795,9 @@ pub async fn list_qualifying_questions( .load() .values() .filter(|q| is_qualified(user.as_ref(), q)) - .filter(|q| !q.followup || has_chain) + .filter(|q| !q.is_followup() || has_chain) + // A dynamic page has no URL of its own to link to. + .filter(|q| !q.is_dynamic()) .map(|q| (q.id.clone(), q.name.clone())) .collect(); out.sort(); @@ -1709,11 +1825,10 @@ pub async fn submit_answer( use crate::server::AppState; let state = expect_context::<AppState>(); - let question = state - .questions - .load() - .get(&question_id) - .cloned() + // resolve_question, not a plain map get: a dynamic page's concrete + // path (what the client holds as its question id) resolves to the + // pattern page it came from. + let question = crate::content::resolve_question(&state.questions.load(), &question_id) .ok_or_else(|| ServerFnError::new("unknown question"))?; let session: tower_sessions::Session = leptos_axum::extract().await?; @@ -1754,6 +1869,14 @@ pub async fn submit_answer( .await .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?; + // Index the node so requires_chain pages can verify lineage. + // Best-effort: the NATS event above is the durable record. + if let Err(e) = + crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, timestamp_ms).await + { + tracing::error!("failed to index chain node: {e}"); + } + // Best-effort: a bucket-write hiccup shouldn't fail a submission the // NATS event has already recorded. if let Some(bucket) = &record_as { diff --git a/src/bin/question_lint.rs b/src/bin/question_lint.rs index db73f8b..7c0d43b 100644 --- a/src/bin/question_lint.rs +++ b/src/bin/question_lint.rs @@ -108,23 +108,38 @@ fn load_aggregates_from_dir( } /// The offline counterpart to `content::load_questions_from_gitea` - -/// same "every `*.yaml` file becomes a `Question` keyed by its own -/// `id`" shape, just reading a local checkout instead of Gitea's API, +/// the same recursive tree walk and `content::build_questions` +/// pipeline (derived ids, relative refs, sections, followup +/// inference), just reading a local checkout instead of Gitea's API, /// for linting a branch that hasn't been pushed yet. fn load_from_dir( dir: &str, ) -> anyhow::Result<std::collections::HashMap<String, content::Question>> { - let mut out = std::collections::HashMap::new(); + let base = std::path::Path::new(dir); + let mut files = Vec::new(); + collect_yaml(base, base, &mut files)?; + content::build_questions(&files) +} + +fn collect_yaml( + base: &std::path::Path, + dir: &std::path::Path, + out: &mut Vec<(String, String)>, +) -> anyhow::Result<()> { for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("yaml") { - continue; + let path = entry?.path(); + if path.is_dir() { + collect_yaml(base, &path, out)?; + } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") { + let rel = path + .strip_prefix(base) + .expect("walked paths sit under their base") + .to_string_lossy() + .replace('\\', "/"); + let raw = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?; + out.push((rel, raw)); } - let raw = std::fs::read_to_string(&path)?; - let question: content::Question = serde_yaml::from_str(&raw) - .map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?; - out.insert(question.id.clone(), question); } - Ok(out) + Ok(()) } diff --git a/src/chain.rs b/src/chain.rs index 93f6efa..6a36b60 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -9,6 +9,60 @@ use sha2::{Digest, Sha256}; /// submitted responses, and a timestamp. Parents are sorted first so the /// hash doesn't depend on the order multiple parents happened to arrive /// in. +/// Where submitted chain nodes are indexed - hash → which question was +/// answered. Small on purpose (no responses), and shared by every +/// portal instance on the JetStream (hashes are globally unique, so +/// cross-site collisions can't happen). This is what lets +/// `requires_chain` pages verify a visitor's `?chain=` actually ends +/// at the question they claim to have answered. +pub const CHAIN_BUCKET: &str = "portal_chains"; + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ChainNode { + pub question_id: String, + pub timestamp_ms: i64, +} + +/// Indexes one submitted node. Best-effort by design (the caller logs +/// and continues): the NATS event is the durable record, this is a +/// lookup convenience. +pub async fn record_node( + js: &async_nats::jetstream::Context, + chain_hash: &str, + question_id: &str, + timestamp_ms: i64, +) -> anyhow::Result<()> { + let store = match js.get_key_value(CHAIN_BUCKET).await { + Ok(store) => store, + Err(_) => { + js.create_key_value(async_nats::jetstream::kv::Config { + bucket: CHAIN_BUCKET.to_string(), + ..Default::default() + }) + .await? + } + }; + let node = ChainNode { + question_id: question_id.to_string(), + timestamp_ms, + }; + store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?; + Ok(()) +} + +/// Looks a chain hash up - `None` covers both "no such node" and +/// "bucket not created yet" (no submissions anywhere), which read the +/// same to a `requires_chain` check: the claimed lineage can't be +/// verified, so the gate stays shut. +pub async fn lookup_node( + js: &async_nats::jetstream::Context, + chain_hash: &str, +) -> Option<ChainNode> { + let store = js.get_key_value(CHAIN_BUCKET).await.ok()?; + let bytes = store.get(chain_hash).await.ok()??; + serde_json::from_slice(&bytes).ok() +} + pub fn hash_node( question_id: &str, parent_hashes: &[String], diff --git a/src/content.rs b/src/content.rs index 94bd88a..7cd7fb6 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,35 +1,85 @@ use serde::{Deserialize, Serialize}; -/// One page: a prompt plus the paths on from it. `id` doubles as the URL -/// path it's served at ("/" is the landing page). Loaded from a plain -/// YAML file per question in a content directory kept in its own git -/// repo (see ../portal-content) - editing content is a content-repo -/// commit, not a Rust rebuild. +/// One page: a prompt plus the paths on from it. The `questions/` +/// directory tree IS the URL tree (`index.yaml` names its directory, +/// `develop/proposal.yaml` serves `/develop/proposal`), so `id` is +/// derived from the file's path - declaring it explicitly still works +/// (and wins, with a logged warning when it disagrees) but is only +/// needed by legacy content. Loaded from plain YAML files in a content +/// directory kept in its own git repo - editing content is a +/// content-repo commit, not a Rust rebuild. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Question { - pub id: String, + /// The URL path this page is served at. Derived from the file path + /// when absent. A `[name]` segment (from a `[name].yaml` file) + /// makes this a dynamic page: `/review/[record]` serves any + /// `/review/<value>`, with the value substituted into `{record}` + /// placeholders in the page's resource keys (see + /// `resolve_question`). #[serde(default)] - pub route: Option<String>, + pub id: String, pub name: String, #[serde(default)] pub description: String, /// Kanidm group required to view/submit this question - `None` means - /// open to anyone, matching every question today. Content-driven - /// on purpose: a gated page like "/review" is just a Question with - /// this set, not a bespoke Rust route. + /// open to anyone. Content-driven on purpose: a gated page like + /// "/review" is just a Question with this set, not a bespoke Rust + /// route. Inherited from the nearest ancestor `_section.yaml` when + /// not set on the question itself. #[serde(default)] pub qualifies: Option<String>, + /// Question id (relative refs resolve against this file's + /// directory) the visitor must have answered - their `?chain=` + /// lineage's tip - to see this page. The provenance counterpart to + /// `qualifies`' identity check. Inherited from `_section.yaml` + /// like `qualifies`. + #[serde(default)] + pub requires_chain: Option<String>, #[serde(default)] pub alternatives: Vec<Alternative>, /// Who to contact if a visitor gets stuck - rendered as a small line - /// on the page. + /// on the page. Inherited from `_section.yaml` when not set. #[serde(default)] pub responsible: Option<Responsible>, /// A page that only makes sense after answering something (the /// post-submission pages) - kept out of the question nav unless the - /// visitor's context carries an answer chain. + /// visitor's context carries an answer chain. Unset means inferred + /// from the file's place in the tree: files nested in a + /// subdirectory are followups unless they're the directory's + /// `index.yaml`; top-level files keep the historical default + /// (not a followup). #[serde(default)] - pub followup: bool, + pub followup: Option<bool>, +} + +impl Question { + pub fn is_followup(&self) -> bool { + self.followup.unwrap_or(false) + } + + /// A dynamic page - one whose id still contains a `[name]` + /// segment. Served per-value via `resolve_question`, never listed + /// in nav, never a valid `action` target. + pub fn is_dynamic(&self) -> bool { + self.id.contains('[') + } +} + +/// Directory-scoped defaults: a `_section.yaml` file applies to every +/// question at or below its directory (nearest ancestor wins per +/// field, and a question's own declaration always overrides). This is +/// what makes a URL prefix a trust boundary - "everything under +/// /review is owner-only" is one line in `review/_section.yaml` +/// instead of a flag per file. Underscore-prefixed files that aren't +/// `_section.yaml` are skipped entirely (drafts). +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SectionConfig { + #[serde(default)] + pub qualifies: Option<String>, + #[serde(default)] + pub requires_chain: Option<String>, + #[serde(default)] + pub responsible: Option<Responsible>, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -479,6 +529,228 @@ pub async fn load_aggregates_from_gitea( /// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see /// `watch_for_reload`), over Gitea's public contents API (no auth - the /// content repo is public). +/// The URL a file at `rel` (path relative to the questions dir, forward +/// slashes) serves: `index.yaml` names its directory, everything else +/// appends its stem. +pub fn route_from_path(rel: &str) -> String { + let stem = rel.strip_suffix(".yaml").unwrap_or(rel); + let mut segments: Vec<&str> = stem.split('/').collect(); + if segments.last() == Some(&"index") { + segments.pop(); + } + if segments.is_empty() { + "/".to_string() + } else { + format!("/{}", segments.join("/")) + } +} + +/// The directory (as a URL prefix) of the file at `rel` - the base +/// relative references resolve against. +pub fn dir_from_path(rel: &str) -> String { + match rel.rsplit_once('/') { + Some((dir, _)) => format!("/{dir}"), + None => "/".to_string(), + } +} + +/// Resolves a possibly-relative question reference (`action:`, +/// `requires_chain:`) against the referencing file's directory: +/// `/x` is absolute, `proposed` names a sibling, `../x` climbs. +pub fn resolve_ref(base_dir: &str, reference: &str) -> String { + if reference.starts_with('/') { + return reference.to_string(); + } + let mut segments: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect(); + for part in reference.split('/') { + match part { + "" | "." => {} + ".." => { + segments.pop(); + } + s => segments.push(s), + } + } + if segments.is_empty() { + "/".to_string() + } else { + format!("/{}", segments.join("/")) + } +} + +/// Whether `path` matches `pattern`, capturing `[name]` segments. +/// Returns the captured (name, value) pairs on a match - empty for an +/// exact literal match. +pub fn path_matches(pattern: &str, path: &str) -> Option<Vec<(String, String)>> { + let pat: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect(); + let got: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if pat.len() != got.len() { + return None; + } + let mut captures = Vec::new(); + for (p, g) in pat.iter().zip(got.iter()) { + if let Some(name) = p.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + if g.is_empty() { + return None; + } + captures.push((name.to_string(), (*g).to_string())); + } else if p != g { + return None; + } + } + Some(captures) +} + +/// Resolves a URL path to its question: an exact id first, else the +/// one dynamic page whose pattern matches, with each captured segment +/// substituted into `{name}` placeholders in the clone's resource keys +/// and its id set to the concrete path (so every follow-up server call +/// - resources, submissions - re-resolves the same way). The +/// substituted key is still looked up in the content-declared bucket, +/// so a visitor varies the key, never the bucket - the same trust +/// shape as a `?chain=` link, where knowing a record's key is holding +/// it. +pub fn resolve_question( + questions: &std::collections::HashMap<String, Question>, + path: &str, +) -> Option<Question> { + if let Some(q) = questions.get(path) { + return Some(q.clone()); + } + for q in questions.values() { + if !q.is_dynamic() { + continue; + } + if let Some(captures) = path_matches(&q.id, path) { + let mut resolved = q.clone(); + resolved.id = path.to_string(); + let substitute = |key: &mut Option<String>| { + if let Some(k) = key { + for (name, value) in &captures { + *k = k.replace(&format!("{{{name}}}"), value); + } + } + }; + for alt in &mut resolved.alternatives { + for feature in &mut alt.features { + if let Some(res) = &mut feature.resource { + substitute(&mut res.key); + } + for req in &mut feature.requirements { + if let Some(res) = &mut req.resource { + substitute(&mut res.key); + } + } + } + } + return Some(resolved); + } + } + None +} + +/// Builds the question map from raw (path, yaml) files - the shared +/// back half of both loaders (Gitea tree and `question_lint --path`). +/// Applies the whole filesystem-routing contract: derived ids, +/// relative-reference resolution, followup inference, `_section.yaml` +/// inheritance, and the tree-shape rules (no id collisions, at most +/// one dynamic page per directory). +#[cfg(feature = "ssr")] +pub fn build_questions( + files: &[(String, String)], +) -> anyhow::Result<std::collections::HashMap<String, Question>> { + // Sections first: (directory prefix, config), shallowest first so a + // later (deeper) section overrides an outer one field-by-field. + let mut sections: Vec<(String, SectionConfig)> = Vec::new(); + for (rel, raw) in files { + let name = rel.rsplit('/').next().unwrap_or(rel); + if name != "_section.yaml" { + continue; + } + let mut section: SectionConfig = serde_yaml::from_str(raw) + .map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?; + let dir = dir_from_path(rel); + if let Some(rc) = §ion.requires_chain { + section.requires_chain = Some(resolve_ref(&dir, rc)); + } + sections.push((dir, section)); + } + sections.sort_by_key(|(dir, _)| dir.len()); + + let mut out = std::collections::HashMap::new(); + let mut dynamic_dirs = std::collections::HashSet::new(); + for (rel, raw) in files { + let name = rel.rsplit('/').next().unwrap_or(rel); + // Underscore files are sections or drafts, never pages. + if name.starts_with('_') { + continue; + } + let mut question: Question = + serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?; + let derived = route_from_path(rel); + let dir = dir_from_path(rel); + + if question.id.is_empty() { + question.id = derived.clone(); + } else if question.id != derived { + tracing::warn!( + "{rel}: declared id {:?} disagrees with its path ({derived}) - the declared id wins, but consider moving the file", + question.id + ); + } + + for alt in &mut question.alternatives { + if let Some(action) = &alt.action { + alt.action = Some(resolve_ref(&dir, action)); + } + } + if let Some(rc) = &question.requires_chain { + question.requires_chain = Some(resolve_ref(&dir, rc)); + } + + if question.followup.is_none() { + // Nested non-index files are flow steps and outcomes - + // followups by construction. Top-level files keep the + // historical flat-repo default. + let nested = rel.contains('/'); + let is_index = name == "index.yaml"; + question.followup = Some(nested && !is_index); + } + + // Nearest-ancestor section fills whatever the question left + // unset (walk deepest-last, so later matches override earlier + // section values but never the question's own). + for (sdir, section) in §ions { + let applies = *sdir == "/" || dir == *sdir || dir.starts_with(&format!("{sdir}/")); + if !applies { + continue; + } + if question.qualifies.is_none() { + question.qualifies = section.qualifies.clone(); + } + if question.requires_chain.is_none() { + question.requires_chain = section.requires_chain.clone(); + } + if question.responsible.is_none() { + question.responsible = section.responsible.clone(); + } + } + + if question.is_dynamic() && !dynamic_dirs.insert(dir.clone()) { + anyhow::bail!( + "{rel}: more than one dynamic ([name].yaml) page in {dir} - matching would be ambiguous" + ); + } + if let Some(previous) = out.insert(question.id.clone(), question) { + anyhow::bail!( + "{rel}: id {:?} is already declared by another file", + previous.id + ); + } + } + Ok(out) +} + #[cfg(feature = "ssr")] pub async fn load_questions_from_gitea( repo_url: &str, @@ -489,45 +761,59 @@ pub async fn load_questions_from_gitea( let api_base = gitea_api_base(repo_url)?; let client = openidconnect::reqwest::Client::new(); - let list_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{subdir}?ref={branch}"); + // One recursive git-trees call for the whole repo instead of a + // contents listing per directory - the tree IS the router now, so + // nested files matter. + let tree_url = + format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"); let listing = client - .get(&list_url) + .get(&tree_url) .send() .await - .map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? + .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))? .error_for_status() - .map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? + .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))? .text() .await - .map_err(|e| anyhow::anyhow!("reading directory listing from {list_url}: {e}"))?; - let entries: Vec<serde_json::Value> = serde_json::from_str(&listing) - .map_err(|e| anyhow::anyhow!("parsing directory listing from {list_url}: {e}"))?; + .map_err(|e| anyhow::anyhow!("reading tree from {tree_url}: {e}"))?; + let tree: serde_json::Value = serde_json::from_str(&listing) + .map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?; + if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) { + anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large"); + } + let prefix = format!("{subdir}/"); - let mut out = std::collections::HashMap::new(); - for entry in entries { - let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); - if !name.ends_with(".yaml") { + let mut files = Vec::new(); + for entry in tree + .get("tree") + .and_then(|v| v.as_array()) + .map(|v| v.as_slice()) + .unwrap_or_default() + { + let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or(""); + if entry.get("type").and_then(|v| v.as_str()) != Some("blob") + || !path.starts_with(&prefix) + || !path.ends_with(".yaml") + { continue; } - let download_url = entry - .get("download_url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("no download_url for {name}"))?; + // Brackets (dynamic pages like `[record].yaml`) must be + // percent-encoded in the raw URL's path. + let encoded = path.replace('[', "%5B").replace(']', "%5D"); + let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}"); let raw = client - .get(download_url) + .get(&raw_url) .send() .await - .map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? + .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? .error_for_status() - .map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? + .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? .text() .await - .map_err(|e| anyhow::anyhow!("reading {name}: {e}"))?; - let question: Question = - serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {name}: {e}"))?; - out.insert(question.id.clone(), question); + .map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; + files.push((path[prefix.len()..].to_string(), raw)); } - Ok(out) + build_questions(&files) } /// Validates every declared transition target (`SelfTransition.to`, @@ -546,15 +832,31 @@ pub fn validate_questions( aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>, ) -> anyhow::Result<()> { for question in questions.values() { + if let Some(target) = &question.requires_chain { + if !questions.contains_key(target) { + anyhow::bail!( + "question {:?}: requires_chain {:?} does not match any declared question id", + question.id, + target + ); + } + } for alternative in &question.alternatives { // A dangling action is a literal dead end: the submit // button navigates to "Nothing here". if let Some(action) = &alternative.action { - if !questions.contains_key(action) { - anyhow::bail!( + match questions.get(action) { + None => anyhow::bail!( "question {:?} alternative {:?}: action {:?} does not match any declared question id", question.id, alternative.name, action - ); + ), + // A dynamic page needs a concrete segment value to + // be a URL - a submit button can't supply one. + Some(target) if target.is_dynamic() => anyhow::bail!( + "question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question", + question.id, alternative.name, action + ), + Some(_) => {} } } if let Some(st) = &alternative.self_transition { @@ -1069,4 +1371,146 @@ alternatives: assert_eq!(site.title.as_deref(), Some("redoal")); assert_eq!(site.hero.kind, "gesture"); } + + #[test] + fn routes_derive_from_paths() { + assert_eq!(route_from_path("index.yaml"), "/"); + assert_eq!(route_from_path("applied.yaml"), "/applied"); + assert_eq!(route_from_path("develop/index.yaml"), "/develop"); + assert_eq!(route_from_path("develop/proposal.yaml"), "/develop/proposal"); + assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]"); + } + + #[test] + fn relative_refs_resolve_against_the_file_dir() { + assert_eq!(resolve_ref("/develop", "proposed"), "/develop/proposed"); + assert_eq!(resolve_ref("/develop", "/subscribed"), "/subscribed"); + assert_eq!(resolve_ref("/develop", "../applied"), "/applied"); + assert_eq!(resolve_ref("/", "applied"), "/applied"); + assert_eq!(resolve_ref("/", ".."), "/"); + } + + #[test] + fn dynamic_patterns_capture_segments() { + assert_eq!(path_matches("/review/[record]", "/review/abc"), + Some(vec![("record".into(), "abc".into())])); + assert_eq!(path_matches("/review/[record]", "/review"), None); + assert_eq!(path_matches("/review/[record]", "/other/abc"), None); + assert_eq!(path_matches("/review", "/review"), Some(vec![])); + } + + #[test] + fn tree_becomes_router_with_inference_and_sections() { + let files = vec![ + ("index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string()), + ("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()), + ("develop/index.yaml".to_string(), "name: Develop\nalternatives:\n - name: propose\n action: proposal\n".to_string()), + ("develop/proposal.yaml".to_string(), "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string()), + ("develop/proposed.yaml".to_string(), "name: Proposed\n".to_string()), + ("review/_section.yaml".to_string(), "qualifies: portal_owners\n".to_string()), + ("review/index.yaml".to_string(), "name: Review\n".to_string()), + ("review/_draft.yaml".to_string(), "not even valid yaml: [\n".to_string()), + ]; + let questions = build_questions(&files).unwrap(); + + // Derived ids and resolved relative actions. + assert_eq!(questions["/"].alternatives[0].action.as_deref(), Some("/applied")); + assert_eq!( + questions["/develop"].alternatives[0].action.as_deref(), + Some("/develop/proposal") + ); + assert_eq!( + questions["/develop/proposal"].alternatives[0].action.as_deref(), + Some("/develop/proposed") + ); + + // Followup inference: nested non-index files are followups, + // index files and top-level files are not (explicit wins). + assert!(!questions["/"].is_followup()); + assert!(questions["/applied"].is_followup()); + assert!(!questions["/develop"].is_followup()); + assert!(questions["/develop/proposal"].is_followup()); + + // Section inheritance gates the directory; drafts are skipped. + assert_eq!(questions["/review"].qualifies.as_deref(), Some("portal_owners")); + assert_eq!(questions["/"].qualifies, None); + assert!(!questions.contains_key("/review/_draft")); + } + + #[test] + fn declared_id_wins_over_path() { + let files = vec![( + "legacy.yaml".to_string(), + "id: /somewhere-else\nname: Legacy\n".to_string(), + )]; + let questions = build_questions(&files).unwrap(); + assert!(questions.contains_key("/somewhere-else")); + } + + #[test] + fn colliding_ids_and_ambiguous_dynamics_are_rejected() { + let collision = vec![ + ("a.yaml".to_string(), "name: A\n".to_string()), + ("b.yaml".to_string(), "id: /a\nname: B\n".to_string()), + ]; + assert!(build_questions(&collision).is_err()); + + let ambiguous = vec![ + ("review/[a].yaml".to_string(), "name: A\n".to_string()), + ("review/[b].yaml".to_string(), "name: B\n".to_string()), + ]; + assert!(build_questions(&ambiguous).is_err()); + } + + #[test] + fn dynamic_pages_resolve_with_key_substitution() { + let files = vec![( + "review/[record].yaml".to_string(), + "name: Record\nalternatives:\n - name: view\n features:\n - name: detail\n resource:\n public: true\n key: \"{record}\"\n source:\n kind: kv\n bucket: applicants\n".to_string(), + )]; + let questions = build_questions(&files).unwrap(); + let page = resolve_question(&questions, "/review/abc123").unwrap(); + assert_eq!(page.id, "/review/abc123"); + assert_eq!( + page.alternatives[0].features[0].resource.as_ref().unwrap().key.as_deref(), + Some("abc123") + ); + assert!(resolve_question(&questions, "/review").is_none()); + // The pattern itself still resolves exactly (it IS an id). + assert!(resolve_question(&questions, "/review/[record]").is_some()); + } + + #[test] + fn requires_chain_resolves_and_validates() { + let files = vec![ + ("shape.yaml".to_string(), "name: Shape\n".to_string()), + ( + "shaped.yaml".to_string(), + "name: Shaped\nrequires_chain: shape\n".to_string(), + ), + ]; + let questions = build_questions(&files).unwrap(); + assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape")); + assert!(validate_questions(&questions, &Default::default()).is_ok()); + + let dangling = vec![( + "shaped.yaml".to_string(), + "name: Shaped\nrequires_chain: /nowhere\n".to_string(), + )]; + let questions = build_questions(&dangling).unwrap(); + assert!(validate_questions(&questions, &Default::default()).is_err()); + } + + #[test] + fn actions_may_not_target_dynamic_pages() { + let files = vec![ + ( + "index.yaml".to_string(), + "name: Home\nalternatives:\n - name: go\n action: /review/[record]\n".to_string(), + ), + ("review/[record].yaml".to_string(), "name: Record\n".to_string()), + ]; + let questions = build_questions(&files).unwrap(); + assert!(validate_questions(&questions, &Default::default()).is_err()); + } } diff --git a/src/main.rs b/src/main.rs index f452364..166ce82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,6 +129,10 @@ async fn main() -> anyhow::Result<()> { let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root); let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root); let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root); + // yes.js sits in public/ (not a wasm-bindgen snippet) so the + // hero's inline early-mount script and the wasm binding can share + // one stable URL - see `mod yes` in app.rs. + let yes_path = format!("{}/yes.js", leptos_options.site_root); let fonts_dir = format!("{}/fonts", leptos_options.site_root); let app = Router::new() @@ -148,6 +152,7 @@ async fn main() -> anyhow::Result<()> { "/swiper-element-bundle.min.js", ServeFile::new(swiper_path), ) + .route_service("/yes.js", ServeFile::new(yes_path)) .leptos_routes_with_context( &state, routes, diff --git a/src/resource.rs b/src/resource.rs index 2261cfc..11410b3 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -122,11 +122,12 @@ fn find_feature( alternative: &str, feature_name: &str, ) -> Result<crate::content::Feature, ServerFnError> { - let question = state - .questions - .load() - .get(question_id) - .cloned() + // resolve_question, not a plain map get: a dynamic page's client + // holds its concrete path as the question id, and resolution is + // also what substitutes the URL segment into the page's resource + // keys - so this lookup is where a `/review/<record>` page's + // feature acquires its record-specific key. + let question = crate::content::resolve_question(&state.questions.load(), question_id) .ok_or_else(|| ServerFnError::new("unknown question"))?; question .alternatives diff --git a/style/main.css b/style/main.css index 9c75f68..2eba14e 100644 --- a/style/main.css +++ b/style/main.css @@ -270,6 +270,11 @@ main.not-found { position: relative; width: 100%; max-width: 46rem; + /* Same jump-avoidance as .gesture-wrap: the canvas (55svh tall) + only exists after hydration, so hold its height open from the + first paint. Plain-vh fallback first, like the canvas itself. */ + min-height: 55vh; + min-height: 55svh; } .hero-gesture .gesture-canvas { @@ -559,6 +564,11 @@ textarea:focus { custom properties - so palette changes go there AND here. */ .gesture-wrap { position: relative; + /* The canvas is created by JS only after wasm hydration - reserve + its 3/2 box now so content below doesn't jump when it appears. + aspect-ratio is a preferred size, so the box still grows when the + echo thumbnail strip shows up under the canvas. */ + aspect-ratio: 3 / 2; } .gesture-canvas {