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! { })}
impl IntoView {
#[component]
fn QuestionView(
question: Question,
+ chain_gate: Option<(String, String)>,
parent_hash: Option,
query_email: Option,
user: Option,
@@ -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! {
+
+
+
+ "This page follows from an answer you don't seem to carry yet."
+
+ {label}
+
+
+
+ }
+ .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::()
+ }
+ 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) ->
+ // 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.
+
}
})}
{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, ServerFnError> {
+pub async fn get_question(
+ path: String,
+ chain: Option,
+) -> Result, ServerFnError> {
+ use crate::content::{path_matches, resolve_question};
use crate::server::AppState;
let state = expect_context::();
- 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::();
- 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> {
- 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 {
+ 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/`, with the value substituted into `{record}`
+ /// placeholders in the page's resource keys (see
+ /// `resolve_question`).
#[serde(default)]
- pub route: Option,
+ 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,
+ /// 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,
#[serde(default)]
pub alternatives: Vec,
/// 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,
/// 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,
+}
+
+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,
+ #[serde(default)]
+ pub requires_chain: Option,
+ #[serde(default)]
+ pub responsible: Option,
}
#[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> {
+ 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,
+ path: &str,
+) -> Option {
+ 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| {
+ 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> {
+ // 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::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,
) -> 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 {
- 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/` 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 {