Compare commits
Generated
+1
-1
@@ -2948,7 +2948,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.24"
|
version = "0.3.34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.25"
|
version = "0.3.34"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ stream.
|
|||||||
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
||||||
parent(s), so a visitor's path through the questions is a verifiable
|
parent(s), so a visitor's path through the questions is a verifiable
|
||||||
lineage; `?chain=` links carry it, and self-service transitions
|
lineage; `?chain=` links carry it, and self-service transitions
|
||||||
(unsubscribe) authorize by holding one.
|
(unsubscribe) authorize by holding one. The question nav reads it
|
||||||
|
too: a `followup` page is offered only when it is on that path (the
|
||||||
|
page the chain verifiably answered) or further down from the current
|
||||||
|
page over `action` edges - one followup path never unlocks another.
|
||||||
- **Live resources** (`src/resource.rs`): content can pull a KV
|
- **Live resources** (`src/resource.rs`): content can pull a KV
|
||||||
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
||||||
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
||||||
|
|||||||
+82
-29
@@ -1,5 +1,5 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::{provide_meta_context, HashedStylesheet, Html, MetaTags, Stylesheet, Title};
|
use leptos_meta::{provide_meta_context, HashedStylesheet, Html, Link, MetaTags, Stylesheet, Title};
|
||||||
use leptos_router::{
|
use leptos_router::{
|
||||||
components::{Route, Router, Routes},
|
components::{Route, Router, Routes},
|
||||||
hooks::{use_location, use_navigate, use_query_map},
|
hooks::{use_location, use_navigate, use_query_map},
|
||||||
@@ -40,6 +40,20 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
|||||||
// once loaded via <link>/<img> on Safari/iOS.
|
// once loaded via <link>/<img> on Safari/iOS.
|
||||||
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
|
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
|
||||||
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
|
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
|
||||||
|
// Fonts hide behind the stylesheet: the browser must
|
||||||
|
// fetch and parse CSS before it discovers them. The
|
||||||
|
// latin subset is on every page's critical path, so
|
||||||
|
// announce it up front; latin-ext and vietnamese stay
|
||||||
|
// unicode-range-gated and load only when script needs
|
||||||
|
// them - preloading those would tax everyone for a few.
|
||||||
|
// (Font preloads require crossorigin even same-origin.)
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/quicksand-semibold-latin.woff2"
|
||||||
|
r#as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
// Server-side, so it can read hash.txt and link the
|
// Server-side, so it can read hash.txt and link the
|
||||||
// content-hashed stylesheet (hash-files = true).
|
// content-hashed stylesheet (hash-files = true).
|
||||||
<HashedStylesheet id="leptos" options=options.clone()/>
|
<HashedStylesheet id="leptos" options=options.clone()/>
|
||||||
@@ -89,10 +103,34 @@ pub fn App() -> impl IntoView {
|
|||||||
.and_then(|s| s.stylesheet)
|
.and_then(|s| s.stylesheet)
|
||||||
.map(|p| format!("/site/{p}"))
|
.map(|p| format!("/site/{p}"))
|
||||||
});
|
});
|
||||||
|
// The wordmark paints in the hero on first view; announcing it in
|
||||||
|
// the head starts the fetch during hydration instead of after. On
|
||||||
|
// redoal.com it lives on the content host, so a preconnect opens
|
||||||
|
// that TLS session while the preload is still queued.
|
||||||
|
let wordmark_href = Memo::new(move |_| {
|
||||||
|
site.get().and_then(|r| r.ok()).and_then(|s| s.wordmark)
|
||||||
|
});
|
||||||
|
let wordmark_origin = Memo::new(move |_| {
|
||||||
|
wordmark_href.get().and_then(|w| {
|
||||||
|
let rest = w.strip_prefix("https://")?;
|
||||||
|
Some(format!("https://{}", rest.split('/').next()?))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// A content-shipped favicon overrides the built-in one; injected
|
||||||
|
// into the head after the static defaults, so it wins.
|
||||||
|
let favicon = Memo::new(move |_| {
|
||||||
|
site.get()
|
||||||
|
.and_then(|r| r.ok())
|
||||||
|
.and_then(|s| s.favicon)
|
||||||
|
.map(|p| format!("/site/{p}"))
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Html attr:lang=move || lang.get()/>
|
<Html attr:lang=move || lang.get()/>
|
||||||
|
{move || wordmark_origin.get().map(|href| view! { <Link rel="preconnect" href=href crossorigin="anonymous"/> })}
|
||||||
|
{move || wordmark_href.get().map(|href| view! { <Link rel="preload" as_="image" href=href/> })}
|
||||||
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
|
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
|
||||||
|
{move || favicon.get().map(|href| view! { <Link rel="icon" href=href/> })}
|
||||||
<Suspense fallback=|| ()>
|
<Suspense fallback=|| ()>
|
||||||
{move || {
|
{move || {
|
||||||
site.get()
|
site.get()
|
||||||
@@ -175,7 +213,10 @@ fn PortalShell() -> impl IntoView {
|
|||||||
|(path, chain)| get_question(path, chain),
|
|(path, chain)| get_question(path, chain),
|
||||||
);
|
);
|
||||||
let user = Resource::new(|| (), |_| current_user());
|
let user = Resource::new(|| (), |_| current_user());
|
||||||
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
|
let nav = Resource::new(
|
||||||
|
move || (path.get(), chain.get()),
|
||||||
|
|(path, chain)| list_qualifying_questions(path, chain),
|
||||||
|
);
|
||||||
let announcements = Resource::new(|| (), |_| list_announcements());
|
let announcements = Resource::new(|| (), |_| list_announcements());
|
||||||
provide_context(QuestionRes(question));
|
provide_context(QuestionRes(question));
|
||||||
provide_context(UserRes(user));
|
provide_context(UserRes(user));
|
||||||
@@ -251,7 +292,6 @@ fn QuestionView(
|
|||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let question_id = question.id.clone();
|
let question_id = question.id.clone();
|
||||||
let has_chain = parent_hash.is_some();
|
|
||||||
|
|
||||||
// The provenance counterpart to the qualifies gate below: a
|
// The provenance counterpart to the qualifies gate below: a
|
||||||
// requires_chain page whose visitor holds no verifiable lineage to
|
// requires_chain page whose visitor holds no verifiable lineage to
|
||||||
@@ -280,7 +320,7 @@ fn QuestionView(
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
// A gate is never a dead end: the same nav as every page.
|
// A gate is never a dead end: the same nav as every page.
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -317,7 +357,7 @@ fn QuestionView(
|
|||||||
}}
|
}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -380,7 +420,7 @@ fn QuestionView(
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
view! { <ResponsibleNote responsible=r/> }
|
view! { <ResponsibleNote responsible=r/> }
|
||||||
})}
|
})}
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
@@ -389,8 +429,7 @@ fn QuestionView(
|
|||||||
/// small nav - how a concern reaches the people it speaks to without
|
/// small nav - how a concern reaches the people it speaks to without
|
||||||
/// claiming front-page space (an owner also sees the gated desks here).
|
/// claiming front-page space (an owner also sees the gated desks here).
|
||||||
#[component]
|
#[component]
|
||||||
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
|
fn QuestionNav(current_id: String) -> impl IntoView {
|
||||||
let _ = has_chain; // keyed into the shared resource by PortalShell
|
|
||||||
let NavRes(nav) = expect_context();
|
let NavRes(nav) = expect_context();
|
||||||
view! {
|
view! {
|
||||||
<Suspense fallback=|| ()>
|
<Suspense fallback=|| ()>
|
||||||
@@ -587,14 +626,10 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
let has_module = module.is_some();
|
let has_module = module.is_some();
|
||||||
// Light theme inverts white-stroke wordmarks to ink. The house
|
// Light theme can invert a white-stroke wordmark to ink. Default:
|
||||||
// mark and the sibling-site marks are all white-stroke SVGs; a
|
// only the built-in house mark inverts; a content-shipped logo
|
||||||
// raster logo is a client brand and keeps its colors.
|
// keeps its colours unless site.yaml opts in with wordmark_invert.
|
||||||
let house_wordmark = site
|
let house_wordmark = site.wordmark_invert.unwrap_or(site.wordmark.is_none());
|
||||||
.wordmark
|
|
||||||
.as_deref()
|
|
||||||
.map(|w| w.ends_with(".svg"))
|
|
||||||
.unwrap_or(true);
|
|
||||||
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
|
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
|
||||||
// Clamped server-side too, but belt and braces for the inline style.
|
// Clamped server-side too, but belt and braces for the inline style.
|
||||||
let wordmark_style = site
|
let wordmark_style = site
|
||||||
@@ -691,7 +726,7 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
|
|||||||
<img src=wordmark alt=site_title style=wordmark_style/>
|
<img src=wordmark alt=site_title style=wordmark_style/>
|
||||||
</a>
|
</a>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{description}</p>
|
<p inner_html=render_inline_markdown(&description)></p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
}
|
}
|
||||||
@@ -1995,16 +2030,21 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
|||||||
Ok(state.site.load().as_ref().clone())
|
Ok(state.site.load().as_ref().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every question the current visitor qualifies for, as (id, name) -
|
/// Every question the visitor may be offered from `current`, as (id,
|
||||||
/// the site nav's data. Context-dependent: an owner session sees the
|
/// name) - the question nav's data. Front-door pages the visitor
|
||||||
/// gated pages too, and `followup` pages only appear once the visitor
|
/// qualifies for are always in (an owner session sees the gated desks
|
||||||
/// carries an answer chain.
|
/// too); `followup` pages and ended events only when they are on the
|
||||||
|
/// visitor's path - the page their verified `?chain=` answered - or
|
||||||
|
/// further down from `current` (see `content::outcomes_reachable_from`).
|
||||||
|
/// A chain unlocks the followup it came from, never every followup on
|
||||||
|
/// the site; an unverifiable hash unlocks nothing.
|
||||||
#[server(endpoint = "list_qualifying_questions")]
|
#[server(endpoint = "list_qualifying_questions")]
|
||||||
pub async fn list_qualifying_questions(
|
pub async fn list_qualifying_questions(
|
||||||
has_chain: bool,
|
current: String,
|
||||||
|
chain: Option<String>,
|
||||||
) -> Result<Vec<(String, String)>, ServerFnError> {
|
) -> Result<Vec<(String, String)>, ServerFnError> {
|
||||||
use crate::auth::{User, SESSION_USER_KEY};
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
use crate::content::is_qualified;
|
use crate::content::{is_qualified, outcomes_reachable_from, Question};
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
@@ -2015,14 +2055,27 @@ pub async fn list_qualifying_questions(
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let mut out: Vec<(String, String)> = state
|
let questions = state.questions.load();
|
||||||
.questions
|
// The one page the chain verifiably answered - the same lookup a
|
||||||
.load()
|
// `requires_chain` gate does, so a forged or foreign hash resolves
|
||||||
|
// to nothing relevant.
|
||||||
|
let answered: Option<String> = match &chain {
|
||||||
|
Some(hash) => crate::chain::lookup_node(&state.jetstream, hash)
|
||||||
|
.await
|
||||||
|
.map(|node| node.question_id),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
// An ended event page is a followup from then on.
|
||||||
|
let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms);
|
||||||
|
let reach = outcomes_reachable_from(&questions, ¤t, &is_outcome);
|
||||||
|
let mut out: Vec<(String, String)> = questions
|
||||||
.values()
|
.values()
|
||||||
.filter(|q| is_qualified(user.as_ref(), q))
|
.filter(|q| is_qualified(user.as_ref(), q))
|
||||||
// An ended event page is a followup from then on - only a
|
.filter(|q| {
|
||||||
// visitor carrying a chain (they answered it) still sees it.
|
!is_outcome(q)
|
||||||
.filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain)
|
|| answered.as_deref() == Some(q.id.as_str())
|
||||||
|
|| reach.contains(&q.id)
|
||||||
|
})
|
||||||
// A live event is announced in the header instead.
|
// A live event is announced in the header instead.
|
||||||
.filter(|q| !q.event_live_at(now_ms))
|
.filter(|q| !q.event_live_at(now_ms))
|
||||||
// A dynamic page has no URL of its own to link to.
|
// A dynamic page has no URL of its own to link to.
|
||||||
|
|||||||
+233
-5
@@ -42,8 +42,11 @@ pub struct Question {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub responsible: Option<Responsible>,
|
pub responsible: Option<Responsible>,
|
||||||
/// A page that only makes sense after answering something (the
|
/// A page that only makes sense after answering something (the
|
||||||
/// post-submission pages) - kept out of the question nav unless the
|
/// post-submission pages) - kept out of the question nav unless it is
|
||||||
/// visitor's context carries an answer chain. Unset means inferred
|
/// on the visitor's path (the page their verified `?chain=` answered)
|
||||||
|
/// or further down from the page they are on, over `action` edges
|
||||||
|
/// (see `outcomes_reachable_from`). One followup path never unlocks
|
||||||
|
/// another. Unset means inferred
|
||||||
/// from the file's place in the tree: files nested in a
|
/// from the file's place in the tree: files nested in a
|
||||||
/// subdirectory are followups unless they're the directory's
|
/// subdirectory are followups unless they're the directory's
|
||||||
/// `index.yaml`; top-level files keep the historical default
|
/// `index.yaml`; top-level files keep the historical default
|
||||||
@@ -191,6 +194,7 @@ pub fn render_inline_markdown(text: &str) -> String {
|
|||||||
Event::Start(Tag::Link { dest_url, .. })
|
Event::Start(Tag::Link { dest_url, .. })
|
||||||
if !(dest_url.starts_with("https://")
|
if !(dest_url.starts_with("https://")
|
||||||
|| dest_url.starts_with("mailto:")
|
|| dest_url.starts_with("mailto:")
|
||||||
|
|| dest_url.starts_with("tel:")
|
||||||
|| dest_url.starts_with('/')) =>
|
|| dest_url.starts_with('/')) =>
|
||||||
{
|
{
|
||||||
in_link += 1;
|
in_link += 1;
|
||||||
@@ -219,7 +223,73 @@ pub fn render_inline_markdown(text: &str) -> String {
|
|||||||
});
|
});
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
html::push_html(&mut out, filtered);
|
html::push_html(&mut out, filtered);
|
||||||
out.trim().to_string()
|
apply_image_hints(out.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a markdown image's title (``)
|
||||||
|
/// into layout: `left`/`right` float via a class, a bare number
|
||||||
|
/// (optionally with `rem`) into a validated `max-width`. Unknown
|
||||||
|
/// tokens are ignored; the title attribute is dropped either way.
|
||||||
|
/// Only touches `<img>` tags in our own render output, and only ever
|
||||||
|
/// emits a numeric max-width - no arbitrary CSS reaches the page.
|
||||||
|
fn apply_image_hints(html: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut rest = html;
|
||||||
|
while let Some(pos) = rest.find("<img ") {
|
||||||
|
out.push_str(&rest[..pos]);
|
||||||
|
let after = &rest[pos..];
|
||||||
|
let end = after.find('>').map(|e| e + 1).unwrap_or(after.len());
|
||||||
|
out.push_str(&rewrite_img_tag(&after[..end]));
|
||||||
|
rest = &after[end..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewrite_img_tag(tag: &str) -> String {
|
||||||
|
// Pull the title value, if any.
|
||||||
|
let title = tag
|
||||||
|
.find("title=\"")
|
||||||
|
.map(|i| &tag[i + 7..])
|
||||||
|
.and_then(|r| r.find('"').map(|e| &r[..e]))
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let mut class = String::new();
|
||||||
|
let mut max_rem: Option<f32> = None;
|
||||||
|
for tok in title.split_whitespace() {
|
||||||
|
match tok {
|
||||||
|
"left" => class = "md-float-left".into(),
|
||||||
|
"right" => class = "md-float-right".into(),
|
||||||
|
other => {
|
||||||
|
if let Ok(n) = other.trim_end_matches("rem").parse::<f32>() {
|
||||||
|
if n > 0.0 && n <= 60.0 {
|
||||||
|
max_rem = Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the title attribute from the tag.
|
||||||
|
let mut cleaned = tag.to_string();
|
||||||
|
if let Some(i) = cleaned.find(" title=\"") {
|
||||||
|
if let Some(e) = cleaned[i + 8..].find('"') {
|
||||||
|
cleaned.replace_range(i..i + 8 + e + 1, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject class/style right after `<img`.
|
||||||
|
let mut attrs = String::new();
|
||||||
|
if !class.is_empty() {
|
||||||
|
attrs.push_str(&format!(" class=\"{class}\""));
|
||||||
|
}
|
||||||
|
if let Some(n) = max_rem {
|
||||||
|
attrs.push_str(&format!(" style=\"max-width:{n}rem\""));
|
||||||
|
}
|
||||||
|
if attrs.is_empty() {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Directory-scoped defaults: a `_section.yaml` file applies to every
|
/// Directory-scoped defaults: a `_section.yaml` file applies to every
|
||||||
@@ -446,6 +516,12 @@ pub struct Requirement {
|
|||||||
/// `ResourceSpec` mechanism a `Feature.resource` uses.
|
/// `ResourceSpec` mechanism a `Feature.resource` uses.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub resource: Option<ResourceSpec>,
|
pub resource: Option<ResourceSpec>,
|
||||||
|
/// `type: select` only - a static list of options, as an
|
||||||
|
/// alternative to a `resource`. Each string is both the option's
|
||||||
|
/// stored value and its label. Faithful to the ancestor format's
|
||||||
|
/// inline enums.
|
||||||
|
#[serde(default)]
|
||||||
|
pub options: Vec<String>,
|
||||||
/// `type: select` only - which field in each item is the option's
|
/// `type: select` only - which field in each item is the option's
|
||||||
/// stable id. Defaults to trying `_id` then `id`.
|
/// stable id. Defaults to trying `_id` then `id`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -517,6 +593,12 @@ pub struct SiteConfig {
|
|||||||
/// `None` falls back to `/wordmark.svg`.
|
/// `None` falls back to `/wordmark.svg`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wordmark: Option<String>,
|
pub wordmark: Option<String>,
|
||||||
|
/// Whether to invert the wordmark in light theme. `None` inverts
|
||||||
|
/// only the built-in house wordmark (a white-stroke SVG); a
|
||||||
|
/// content-shipped logo keeps its own colours unless it sets this
|
||||||
|
/// true (e.g. a sibling site's white-stroke mark).
|
||||||
|
#[serde(default)]
|
||||||
|
pub wordmark_invert: Option<bool>,
|
||||||
/// Wordmark display height in rem (0.5–6.0). `None` keeps the
|
/// Wordmark display height in rem (0.5–6.0). `None` keeps the
|
||||||
/// stylesheet's default. Raster logos look best at or below their
|
/// stylesheet's default. Raster logos look best at or below their
|
||||||
/// intrinsic pixel height.
|
/// intrinsic pixel height.
|
||||||
@@ -532,6 +614,10 @@ pub struct SiteConfig {
|
|||||||
/// same-origin at `/site/<path>`; plain path only.
|
/// same-origin at `/site/<path>`; plain path only.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stylesheet: Option<String>,
|
pub stylesheet: Option<String>,
|
||||||
|
/// A content-repo-relative favicon (svg/png/ico), served at
|
||||||
|
/// `/site/<path>` and used in place of the built-in one.
|
||||||
|
#[serde(default)]
|
||||||
|
pub favicon: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hero: HeroConfig,
|
pub hero: HeroConfig,
|
||||||
}
|
}
|
||||||
@@ -594,6 +680,14 @@ impl SiteConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(icon) = &self.favicon {
|
||||||
|
let ok = [".svg", ".png", ".ico"].iter().any(|e| icon.ends_with(e));
|
||||||
|
if !is_safe_site_path(icon) || !ok {
|
||||||
|
anyhow::bail!(
|
||||||
|
"site.yaml: favicon {icon:?} must be a plain repo-relative .svg/.png/.ico path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -831,6 +925,74 @@ pub fn path_matches(pattern: &str, path: &str) -> Option<Vec<(String, String)>>
|
|||||||
Some(captures)
|
Some(captures)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The question an alternative's `action` lands on, as a question id:
|
||||||
|
/// a plain path resolves directly, a templated one
|
||||||
|
/// (`/shape/{curve.key}`) to the dynamic page whose pattern has the
|
||||||
|
/// same shape (`/shape/[key]`). `None` when no page declares it.
|
||||||
|
pub fn action_target_id(
|
||||||
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
|
action: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
if questions.contains_key(action) {
|
||||||
|
return Some(action.to_string());
|
||||||
|
}
|
||||||
|
let want: Vec<&str> = action.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
questions
|
||||||
|
.values()
|
||||||
|
.filter(|q| q.is_dynamic())
|
||||||
|
.map(|q| &q.id)
|
||||||
|
.find(|id| {
|
||||||
|
let pat: Vec<&str> = id.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
pat.len() == want.len()
|
||||||
|
&& pat
|
||||||
|
.iter()
|
||||||
|
.zip(&want)
|
||||||
|
.all(|(p, w)| p == w || (p.starts_with('[') && w.starts_with('{')))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome pages "further down" from `from`: every `is_outcome`
|
||||||
|
/// question reachable from it over `alternatives[].action` edges,
|
||||||
|
/// walking only *through* outcome pages. A front-door page (not an
|
||||||
|
/// outcome) is where the walk ends, so a "back to the start" action
|
||||||
|
/// never fans out into the whole site. `from` itself is never in the
|
||||||
|
/// result. This, plus the one page a visitor's verified chain answered,
|
||||||
|
/// decides which followups the question nav offers - one followup path
|
||||||
|
/// never unlocks another (an email-link page like redoal's `/watching`,
|
||||||
|
/// which nothing links to, stays out of every nav).
|
||||||
|
pub fn outcomes_reachable_from(
|
||||||
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
|
from: &str,
|
||||||
|
is_outcome: &dyn Fn(&Question) -> bool,
|
||||||
|
) -> std::collections::HashSet<String> {
|
||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut queue: VecDeque<Question> = VecDeque::new();
|
||||||
|
if let Some(q) = resolve_question(questions, from) {
|
||||||
|
queue.push_back(q);
|
||||||
|
}
|
||||||
|
while let Some(q) = queue.pop_front() {
|
||||||
|
for action in q.alternatives.iter().filter_map(|a| a.action.as_deref()) {
|
||||||
|
let Some(id) = action_target_id(questions, action) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// A page's own dynamic pattern (`/shape/abc` -> `/shape/[key]`)
|
||||||
|
// is itself, not somewhere further down.
|
||||||
|
if id == from || path_matches(&id, from).is_some() || seen.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = &questions[&id];
|
||||||
|
if !is_outcome(target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(id.clone());
|
||||||
|
queue.push_back(target.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves a URL path to its question: an exact id first, else the
|
/// Resolves a URL path to its question: an exact id first, else the
|
||||||
/// one dynamic page whose pattern matches, with each captured segment
|
/// one dynamic page whose pattern matches, with each captured segment
|
||||||
/// substituted into `{name}` placeholders in the clone's resource keys
|
/// substituted into `{name}` placeholders in the clone's resource keys
|
||||||
@@ -1204,9 +1366,12 @@ pub fn validate_questions(
|
|||||||
.collect();
|
.collect();
|
||||||
for feature in &alternative.features {
|
for feature in &alternative.features {
|
||||||
for requirement in &feature.requirements {
|
for requirement in &feature.requirements {
|
||||||
if requirement.kind == "select" && requirement.resource.is_none() {
|
if requirement.kind == "select"
|
||||||
|
&& requirement.resource.is_none()
|
||||||
|
&& requirement.options.is_empty()
|
||||||
|
{
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares no resource to select from",
|
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options",
|
||||||
question.id, alternative.name, feature.name, requirement.name
|
question.id, alternative.name, feature.name, requirement.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1454,6 +1619,8 @@ pub async fn site_asset_handler(
|
|||||||
Some("json") => "application/json",
|
Some("json") => "application/json",
|
||||||
Some("png") => "image/png",
|
Some("png") => "image/png",
|
||||||
Some("webp") => "image/webp",
|
Some("webp") => "image/webp",
|
||||||
|
Some("avif") => "image/avif",
|
||||||
|
Some("ico") => "image/x-icon",
|
||||||
Some("woff2") => "font/woff2",
|
Some("woff2") => "font/woff2",
|
||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
};
|
};
|
||||||
@@ -1865,6 +2032,48 @@ alternatives:
|
|||||||
assert!(!questions.contains_key("/review/_draft"));
|
assert!(!questions.contains_key("/review/_draft"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn followups_surface_by_reach_not_by_carrying_any_chain() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let files = vec![
|
||||||
|
// Front door with three outcomes, one of them templated onto
|
||||||
|
// a dynamic page.
|
||||||
|
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n - name: subscribe\n action: subscribed\n - name: draw\n action: /shape/{curve.key}\n".to_string()),
|
||||||
|
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
|
||||||
|
("subscribed.yaml".to_string(), "name: Subscribed\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
|
||||||
|
// Nested -> followup by inference; leads on to /subscribed.
|
||||||
|
("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n - name: back\n action: /\n".to_string()),
|
||||||
|
// Reached only by an out-of-band link: nothing points here.
|
||||||
|
("watching.yaml".to_string(), "name: Watching\nfollowup: true\n".to_string()),
|
||||||
|
// A second, unrelated path with its own outcome.
|
||||||
|
("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n".to_string()),
|
||||||
|
("takk.yaml".to_string(), "name: Takk\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
|
||||||
|
];
|
||||||
|
let questions = build_questions(&files).unwrap();
|
||||||
|
let is_outcome = |q: &Question| q.is_followup();
|
||||||
|
let reach = |from: &str| outcomes_reachable_from(&questions, from, &is_outcome);
|
||||||
|
let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
// Templated actions resolve to the dynamic page's pattern.
|
||||||
|
assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]"));
|
||||||
|
assert_eq!(action_target_id(&questions, "/nowhere"), None);
|
||||||
|
|
||||||
|
// From the front door: its own outcomes, and what they lead on
|
||||||
|
// to - but not the other path's outcome, nor the orphan.
|
||||||
|
assert_eq!(reach("/"), set(&["/applied", "/subscribed", "/shape/[key]"]));
|
||||||
|
// The other path only sees its own outcome. Its "back to /"
|
||||||
|
// edge ends the walk at the front door instead of fanning out.
|
||||||
|
assert_eq!(reach("/kontakt"), set(&["/takk"]));
|
||||||
|
// Standing on an outcome: nothing further down through outcomes.
|
||||||
|
assert_eq!(reach("/subscribed"), set(&[]));
|
||||||
|
assert_eq!(reach("/takk"), set(&[]));
|
||||||
|
// The email-link page reaches nothing and nothing reaches it.
|
||||||
|
assert_eq!(reach("/watching"), set(&[]));
|
||||||
|
// A concrete dynamic path resolves to its pattern and walks on;
|
||||||
|
// the pattern itself is not "further down" from its own page.
|
||||||
|
assert_eq!(reach("/shape/abc"), set(&["/subscribed"]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn declared_id_wins_over_path() {
|
fn declared_id_wins_over_path() {
|
||||||
let files = vec)"), "bad");
|
assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad");
|
||||||
|
assert_eq!(
|
||||||
|
render_inline_markdown("[ring](tel:+4791180485)"),
|
||||||
|
"<a href=\"tel:+4791180485\">ring</a>"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render_inline_markdown(""),
|
render_inline_markdown(""),
|
||||||
"<img src=\"https://x.no/a.jpg\" alt=\"site\" />"
|
"<img src=\"https://x.no/a.jpg\" alt=\"site\" />"
|
||||||
@@ -2073,6 +2286,21 @@ alternatives:
|
|||||||
render_inline_markdown(""),
|
render_inline_markdown(""),
|
||||||
"<img src=\"/images/a.jpg\" alt=\"local\" />"
|
"<img src=\"/images/a.jpg\" alt=\"local\" />"
|
||||||
);
|
);
|
||||||
|
// Image title hints: float + max-width, title dropped.
|
||||||
|
assert_eq!(
|
||||||
|
render_inline_markdown(""),
|
||||||
|
"<img class=\"md-float-right\" style=\"max-width:9rem\" src=\"https://x.no/j.jpg\" alt=\"J\" />"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render_inline_markdown(""),
|
||||||
|
"<img style=\"max-width:12rem\" src=\"https://x.no/j.jpg\" alt=\"J\" />"
|
||||||
|
);
|
||||||
|
// Unknown hint tokens are ignored; a safe image with no title
|
||||||
|
// is untouched.
|
||||||
|
assert_eq!(
|
||||||
|
render_inline_markdown(""),
|
||||||
|
"<img src=\"https://x.no/j.jpg\" alt=\"J\" />"
|
||||||
|
);
|
||||||
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
|
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
|
||||||
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
|
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ pub async fn get_requirement_options(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|r| r.name == requirement_name)
|
.find(|r| r.name == requirement_name)
|
||||||
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
|
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
|
||||||
|
// Inline options: return them as {id,label} objects, the shape the
|
||||||
|
// SelectField renders, without touching a resource.
|
||||||
|
if !requirement.options.is_empty() {
|
||||||
|
let items: Vec<serde_json::Value> = requirement
|
||||||
|
.options
|
||||||
|
.iter()
|
||||||
|
.map(|o| serde_json::json!({ "id": o, "label": o }))
|
||||||
|
.collect();
|
||||||
|
return Ok(serde_json::Value::Array(items));
|
||||||
|
}
|
||||||
let resource = requirement
|
let resource = requirement
|
||||||
.resource
|
.resource
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
+34
-2
@@ -147,6 +147,10 @@ a {
|
|||||||
main.loading,
|
main.loading,
|
||||||
main.not-found {
|
main.not-found {
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
|
/* svh so wrapper browsers that resize the webview as their toolbar
|
||||||
|
animates (Firefox/Chrome iOS) don't reflow this placeholder - the
|
||||||
|
small viewport is a fixed value. Same ladder as .hero-piece. */
|
||||||
|
min-height: 60svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -368,8 +372,12 @@ main.not-found {
|
|||||||
.alt-image {
|
.alt-image {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 14rem;
|
height: auto;
|
||||||
object-fit: cover;
|
/* Natural aspect, not a cropped band; a very tall image is still
|
||||||
|
bounded so it can't tower. The deck below overrides for its
|
||||||
|
fixed-height cards. */
|
||||||
|
max-height: 32rem;
|
||||||
|
object-fit: contain;
|
||||||
border-radius: calc(var(--radius) - 0.3rem);
|
border-radius: calc(var(--radius) - 0.3rem);
|
||||||
margin-bottom: 1.1rem;
|
margin-bottom: 1.1rem;
|
||||||
}
|
}
|
||||||
@@ -552,6 +560,30 @@ main.not-found {
|
|||||||
border: 0.06rem solid var(--line);
|
border: 0.06rem solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Content hint via a markdown image title (``):
|
||||||
|
float and shrink so text wraps around a portrait. */
|
||||||
|
.alt-description img.md-float-left,
|
||||||
|
.item-card-description img.md-float-left,
|
||||||
|
.feature p img.md-float-left,
|
||||||
|
.alt-description img.md-float-right,
|
||||||
|
.item-card-description img.md-float-right,
|
||||||
|
.feature p img.md-float-right {
|
||||||
|
width: auto;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
.alt-description img.md-float-left,
|
||||||
|
.item-card-description img.md-float-left,
|
||||||
|
.feature p img.md-float-left {
|
||||||
|
float: left;
|
||||||
|
margin: 0.2rem 1.1rem 0.5rem 0;
|
||||||
|
}
|
||||||
|
.alt-description img.md-float-right,
|
||||||
|
.item-card-description img.md-float-right,
|
||||||
|
.feature p img.md-float-right {
|
||||||
|
float: right;
|
||||||
|
margin: 0.2rem 0 0.5rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.alt-description code,
|
.alt-description code,
|
||||||
.feature p code {
|
.feature p code {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
|||||||
Reference in New Issue
Block a user