Filesystem routes, sections, dynamic segments; instant YES hero
Test / test (push) Successful in 23s
Test / test (push) Successful in 23s
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/<value> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b737e0a7e7
commit
452ea88fbf
+136
-13
@@ -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 <Title> 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 {
|
||||
|
||||
Reference in New Issue
Block a user