Compare commits

...
20 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 b24b4f5582 Release 0.3.34
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-11 19:40:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 74bba2e9b1 Question nav: offer followups by reach, not by carrying any chain
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.

The nav now resolves the chain hash to the one question it verifiably
answered (the same `lookup_node` a `requires_chain` gate uses) and
offers a followup only when it is on that path or further down from
the current page over `action` edges. `content::outcomes_reachable_from`
walks those edges through followup pages only, so a "back to the
start" action ends the walk at the front door instead of fanning out.
Templated actions (`/shape/{curve.key}`) resolve to their dynamic
page's pattern. No schema change: the `followup` flag keeps meaning
"not a front door", and the edges were already in the content.

Also: `main.loading`/`main.not-found` gets the same `svh` ladder as
`.hero-piece`, the last bare `vh` in the stylesheet, so wrapper
browsers that resize the webview as their toolbar animates don't
reflow the placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-11 19:40:46 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bd12dd4e99 Release 0.3.33
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-02 11:48:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b044780e61 Head preloads: latin font, wordmark, content-host preconnect
Fonts hide behind the stylesheet - the browser parses CSS before it
finds them - so the latin Quicksand subset (every page's critical
path) is announced in the shell head; latin-ext/vietnamese stay
unicode-range-gated. The wordmark preloads from the resolved site
config, with a preconnect to its origin when it lives on the content
host (redoal.com's does), opening that TLS session early.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-02 11:48:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9c22b52fda Release 0.3.32
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 22:12:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 01504fb120 Per-site favicon via site.yaml
A content repo can ship a favicon (svg/png/ico) named in site.yaml;
portal serves it at /site/<path> and injects a <link rel=icon> after
the static defaults so it wins. Also serves .ico assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 22:12:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b25a5839b0 Release 0.3.31
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:48:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c4609f4370 Markdown links allow tel:
Phone numbers can now be [+47 …](tel:+47…) links in descriptions,
same gate as https/mailto/relative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:48:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2c94ba4379 Release 0.3.30
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 065bd0a86a Single alt-image shows natural height
Was cropped to a 14rem band via object-fit: cover; now shows the whole
image at natural aspect, bounded at 32rem so a tall one can't tower.
The image deck keeps its fixed-height cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 035ad7830b Release 0.3.29
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bf7f2e11e7 Markdown image layout hints + AVIF assets
Content-only image control: a markdown image title carries layout —
left/right to float text around it, a number for max-width in rem
(validated, capped) — translated to md-float-* classes and a numeric
inline max-width. No arbitrary CSS reaches the page. Portal also now
serves .avif site assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 57b70c8445 Release 0.3.28
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0d3221c22f Hero description renders inline markdown
The question-level description (hero subtitle) was plain text, so
**bold** showed literal asterisks. Now it runs through
render_inline_markdown like every other description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 013fd7151e Release 0.3.27
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:26:34 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 99b29bb09f wordmark_invert: explicit, not inferred from .svg
A content-shipped colour logo (Klingenberg's red tile) was being
inverted to teal in light theme by the .svg heuristic. Invert is now
an explicit site.yaml flag defaulting to house-mark-only; content
logos keep their colours unless they opt in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:26:34 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b08e1af139 Release 0.3.26
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c1cdc53187 Select fields: inline static options
A select requirement can now declare a plain options: [A, B, C] list
instead of a resource - each string is both value and label. Restores
the ancestor question format's inline enums (used by the klingenberg
port) without needing a resource endpoint for a fixed list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 4fee02ab82 Release 0.3.25
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:12:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 405bb98185 Content-shipped stylesheet override (site.yaml stylesheet)
A content repo can now ship a CSS file named in site.yaml's
stylesheet field; portal serves it same-origin at /site/<path> and
leptos_meta appends it after the default stylesheet so content rules
win. Plain repo-relative .css path only, validated on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:12:06 +02:00
7 changed files with 389 additions and 39 deletions
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.23"
version = "0.3.34"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.24"
version = "0.3.34"
edition = "2021"
[lib]
+4 -1
View File
@@ -70,7 +70,10 @@ stream.
- **Answer chains** (`src/chain.rs`): each submission hashes its
parent(s), so a visitor's path through the questions is a verifiable
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
bucket, Gitea starred/org repos/releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
+94 -29
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, HashedStylesheet, Html, MetaTags, Title};
use leptos_meta::{provide_meta_context, HashedStylesheet, Html, Link, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
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.
<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"/>
// 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
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
@@ -79,8 +93,44 @@ pub fn App() -> impl IntoView {
});
provide_context(Lang(lang));
// A content-shipped stylesheet (site.yaml `stylesheet:`) layered
// after the default one - leptos_meta appends it at the MetaTags
// slot, which the shell places after HashedStylesheet, so content
// rules win at equal specificity.
let custom_css = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.stylesheet)
.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! {
<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 || favicon.get().map(|href| view! { <Link rel="icon" href=href/> })}
<Suspense fallback=|| ()>
{move || {
site.get()
@@ -163,7 +213,10 @@ fn PortalShell() -> impl IntoView {
|(path, chain)| get_question(path, chain),
);
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());
provide_context(QuestionRes(question));
provide_context(UserRes(user));
@@ -239,7 +292,6 @@ fn QuestionView(
site: SiteConfig,
) -> impl IntoView {
let question_id = question.id.clone();
let has_chain = parent_hash.is_some();
// The provenance counterpart to the qualifies gate below: a
// requires_chain page whose visitor holds no verifiable lineage to
@@ -268,7 +320,7 @@ fn QuestionView(
</section>
</div>
// 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();
}
@@ -305,7 +357,7 @@ fn QuestionView(
}}
</section>
</div>
<QuestionNav current_id=question_id has_chain=has_chain/>
<QuestionNav current_id=question_id/>
}
.into_any();
}
@@ -368,7 +420,7 @@ fn QuestionView(
.map(|r| {
view! { <ResponsibleNote responsible=r/> }
})}
<QuestionNav current_id=question_id has_chain=has_chain/>
<QuestionNav current_id=question_id/>
}
.into_any()
}
@@ -377,8 +429,7 @@ fn QuestionView(
/// small nav - how a concern reaches the people it speaks to without
/// claiming front-page space (an owner also sees the gated desks here).
#[component]
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
let _ = has_chain; // keyed into the shared resource by PortalShell
fn QuestionNav(current_id: String) -> impl IntoView {
let NavRes(nav) = expect_context();
view! {
<Suspense fallback=|| ()>
@@ -575,14 +626,10 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
None
};
let has_module = module.is_some();
// Light theme inverts white-stroke wordmarks to ink. The house
// mark and the sibling-site marks are all white-stroke SVGs; a
// raster logo is a client brand and keeps its colors.
let house_wordmark = site
.wordmark
.as_deref()
.map(|w| w.ends_with(".svg"))
.unwrap_or(true);
// Light theme can invert a white-stroke wordmark to ink. Default:
// only the built-in house mark inverts; a content-shipped logo
// keeps its colours unless site.yaml opts in with wordmark_invert.
let house_wordmark = site.wordmark_invert.unwrap_or(site.wordmark.is_none());
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
// Clamped server-side too, but belt and braces for the inline style.
let wordmark_style = site
@@ -679,7 +726,7 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
<h1>{title}</h1>
<p>{description}</p>
<p inner_html=render_inline_markdown(&description)></p>
</div>
</header>
}
@@ -1983,16 +2030,21 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
Ok(state.site.load().as_ref().clone())
}
/// Every question the current visitor qualifies for, as (id, name) -
/// the site nav's data. Context-dependent: an owner session sees the
/// gated pages too, and `followup` pages only appear once the visitor
/// carries an answer chain.
/// Every question the visitor may be offered from `current`, as (id,
/// name) - the question nav's data. Front-door pages the visitor
/// qualifies for are always in (an owner session sees the gated desks
/// 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")]
pub async fn list_qualifying_questions(
has_chain: bool,
current: String,
chain: Option<String>,
) -> Result<Vec<(String, String)>, ServerFnError> {
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;
let state = expect_context::<AppState>();
@@ -2003,14 +2055,27 @@ pub async fn list_qualifying_questions(
.map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let mut out: Vec<(String, String)> = state
.questions
.load()
let questions = state.questions.load();
// The one page the chain verifiably answered - the same lookup a
// `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, &current, &is_outcome);
let mut out: Vec<(String, String)> = questions
.values()
.filter(|q| is_qualified(user.as_ref(), q))
// An ended event page is a followup from then on - only a
// visitor carrying a chain (they answered it) still sees it.
.filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain)
.filter(|q| {
!is_outcome(q)
|| answered.as_deref() == Some(q.id.as_str())
|| reach.contains(&q.id)
})
// A live event is announced in the header instead.
.filter(|q| !q.event_live_at(now_ms))
// A dynamic page has no URL of its own to link to.
+245 -5
View File
@@ -42,8 +42,11 @@ pub struct Question {
#[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. Unset means inferred
/// post-submission pages) - kept out of the question nav unless it is
/// 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
/// subdirectory are followups unless they're the directory's
/// `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, .. })
if !(dest_url.starts_with("https://")
|| dest_url.starts_with("mailto:")
|| dest_url.starts_with("tel:")
|| dest_url.starts_with('/')) =>
{
in_link += 1;
@@ -219,7 +223,73 @@ pub fn render_inline_markdown(text: &str) -> String {
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().to_string()
apply_image_hints(out.trim())
}
/// Translate a markdown image's title (`![alt](url "right 9rem")`)
/// 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
@@ -446,6 +516,12 @@ pub struct Requirement {
/// `ResourceSpec` mechanism a `Feature.resource` uses.
#[serde(default)]
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
/// stable id. Defaults to trying `_id` then `id`.
#[serde(default)]
@@ -517,6 +593,12 @@ pub struct SiteConfig {
/// `None` falls back to `/wordmark.svg`.
#[serde(default)]
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
/// stylesheet's default. Raster logos look best at or below their
/// intrinsic pixel height.
@@ -527,6 +609,15 @@ pub struct SiteConfig {
/// `None` means "en"; unknown codes fall back to English per key.
#[serde(default)]
pub lang: Option<String>,
/// A content-repo-relative CSS file (e.g. "custom.css") layered
/// *after* the default stylesheet, so it overrides. Served
/// same-origin at `/site/<path>`; plain path only.
#[serde(default)]
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)]
pub hero: HeroConfig,
}
@@ -582,6 +673,21 @@ impl SiteConfig {
}
other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"),
}
if let Some(sheet) = &self.stylesheet {
if !is_safe_site_path(sheet) || !sheet.ends_with(".css") {
anyhow::bail!(
"site.yaml: stylesheet {sheet:?} must be a plain repo-relative .css path"
);
}
}
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(())
}
}
@@ -819,6 +925,74 @@ pub fn path_matches(pattern: &str, path: &str) -> Option<Vec<(String, String)>>
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
/// one dynamic page whose pattern matches, with each captured segment
/// substituted into `{name}` placeholders in the clone's resource keys
@@ -1192,9 +1366,12 @@ pub fn validate_questions(
.collect();
for feature in &alternative.features {
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!(
"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
);
}
@@ -1442,6 +1619,8 @@ pub async fn site_asset_handler(
Some("json") => "application/json",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
};
@@ -1853,6 +2032,48 @@ alternatives:
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]
fn declared_id_wins_over_path() {
let files = vec![(
@@ -2052,6 +2273,10 @@ alternatives:
fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
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!(
render_inline_markdown("![site](https://x.no/a.jpg)"),
"<img src=\"https://x.no/a.jpg\" alt=\"site\" />"
@@ -2061,6 +2286,21 @@ alternatives:
render_inline_markdown("![local](/images/a.jpg)"),
"<img src=\"/images/a.jpg\" alt=\"local\" />"
);
// Image title hints: float + max-width, title dropped.
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"right 9rem\")"),
"<img class=\"md-float-right\" style=\"max-width:9rem\" src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"12\")"),
"<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("![J](https://x.no/j.jpg \"wat\")"),
"<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("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
}
+10
View File
@@ -68,6 +68,16 @@ pub async fn get_requirement_options(
.iter()
.find(|r| r.name == requirement_name)
.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
.resource
.as_ref()
+34 -2
View File
@@ -147,6 +147,10 @@ a {
main.loading,
main.not-found {
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;
flex-direction: column;
align-items: center;
@@ -368,8 +372,12 @@ main.not-found {
.alt-image {
display: block;
width: 100%;
max-height: 14rem;
object-fit: cover;
height: auto;
/* 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);
margin-bottom: 1.1rem;
}
@@ -552,6 +560,30 @@ main.not-found {
border: 0.06rem solid var(--line);
}
/* Content hint via a markdown image title (`![alt](url "right 9rem")`):
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,
.feature p code {
font-size: 0.9em;