//! Content: the YAML a site is made of, loaded from its Gitea repo, //! resolved into a router, validated, and rendered. The types and the //! resolution rules live here; the rest is split by concern and //! re-exported, so every path stays `content::x`: //! //! - `gitea`: loading and the reload watcher; //! - `validate`: the checks a repo must pass before it serves; //! - `markdown`: inline markdown for descriptions; //! - `handlers`: the Axum routes that expose content and buckets. use serde::{Deserialize, Serialize}; // Loading is server-only; the rest is shared with the browser build. #[cfg(feature = "ssr")] mod gitea; #[cfg(feature = "ssr")] mod variants; mod handlers; mod markdown; mod validate; #[cfg(feature = "ssr")] pub use gitea::*; #[cfg(feature = "ssr")] pub use variants::*; pub use handlers::*; pub use markdown::*; pub use validate::*; /// 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 { /// 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 id: String, pub name: String, #[serde(default)] pub description: String, /// Kanidm group required to view/submit this question - `None` means /// 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. 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 until the /// visitor has earned it: their verified `?chain=` answered the /// alternative that leads there, or it hangs off an earned page by /// a link that records nothing (see `earned_followups`). Nothing /// behind an answer they have not given is offered. 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: Option, /// This page announces something with a time window. While the /// window is open the page is announced in the header (see /// `app::Announcements`) instead of listed in the footer nav; once /// it closes the page behaves like a `followup` (visible only to /// visitors carrying an answer chain) and a "post what happened" /// task lands in the `portal_events` desk (see `announce.rs`). #[serde(default)] pub event: Option, /// Where this question is about, when it is about somewhere: a /// still map under the question, drawn from this origin. `event` /// makes a page an occasion with a time; this makes one a place, /// and a `type: location` field on the page opens its map here /// rather than on the whole world. #[serde(default)] pub place: Option, /// Pictures across the page, under the question: a strip the full /// width of the screen that a visitor swipes through, each picture /// with its caption laid over it. What the business has built, /// shown rather than described. #[serde(default, skip_serializing_if = "Option::is_none")] pub slideshow: Option, } /// A point a page is about. `zoom` is a map zoom level, 0 (the globe) /// to 22 (a doorstep); 15 shows a street and its neighbours. `name` is /// what to call it in words, for anyone who cannot see the map. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Place { pub lat: f64, pub lon: f64, #[serde(default = "default_zoom")] pub zoom: f64, #[serde(default)] pub name: Option, } fn default_zoom() -> f64 { 15.0 } impl Place { /// `lat,lon,zoom`, the form a `type: location` field takes as its /// opening view. pub fn as_value(&self) -> String { format!("{},{},{}", self.lat, self.lon, self.zoom) } pub fn validate(&self) -> Result<(), String> { if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) { return Err(format!("lat {} / lon {} is not a point on Earth", self.lat, self.lon)); } if !(0.0..=22.0).contains(&self.zoom) { return Err(format!("zoom {} is outside 0 to 22", self.zoom)); } if self.name.as_ref().is_some_and(|n| n.trim().is_empty()) { return Err("place.name is empty; leave it out or say where this is".to_string()); } Ok(()) } } /// A page's announcement window. `starts` is RFC 3339 with an offset /// (`2026-09-12T18:00:00+02:00`); `duration` is a plain span like /// `3h`, `90m`, `2d`, `1d 6h`. Optional `place` is shown verbatim. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct EventConfig { pub starts: String, pub duration: String, #[serde(default)] pub place: Option, } /// `3h`, `90m`, `2d`, `1d 6h 30m` -> milliseconds. Whole units only; /// the smallest is a minute. pub fn parse_duration_ms(text: &str) -> Result { let mut total: i64 = 0; let mut seen = false; for token in text.split_whitespace() { let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len()); let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?; let per = match unit { "m" => 60_000, "h" => 3_600_000, "d" => 86_400_000, _ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")), }; total += n * per; seen = true; } if !seen || total <= 0 { return Err(format!("bad duration {text:?}: must be positive")); } Ok(total) } #[cfg(feature = "ssr")] impl EventConfig { /// `(starts_ms, ends_ms)` as Unix milliseconds. pub fn window_ms(&self) -> Result<(i64, i64), String> { let starts = chrono::DateTime::parse_from_rfc3339(&self.starts) .map_err(|e| format!("bad starts {:?}: {e} (RFC 3339 with offset)", self.starts))? .timestamp_millis(); Ok((starts, starts + parse_duration_ms(&self.duration)?)) } } impl Question { pub fn is_followup(&self) -> bool { self.followup.unwrap_or(false) } /// The announcement window is still open at `now_ms` (Unix ms). #[cfg(feature = "ssr")] pub fn event_live_at(&self, now_ms: i64) -> bool { self.event .as_ref() .and_then(|e| e.window_ms().ok()) .is_some_and(|(_, ends)| now_ms < ends) } /// The announcement window has closed at `now_ms`. #[cfg(feature = "ssr")] pub fn event_ended_at(&self, now_ms: i64) -> bool { self.event .as_ref() .and_then(|e| e.window_ms().ok()) .is_some_and(|(_, ends)| now_ms >= ends) } /// 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('[') } } /// The signed-in person's own details into the fields that ask for /// them: `value: "{user.email}"` becomes their address and the field /// hidden, since asking someone who is known to say who they are is /// the form doubting them. Without a session the placeholder is /// emptied, and the field asks. Only the whole of a value is filled /// this way; a value that mixes a user placeholder with other text is /// left alone. pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) { for alt in &mut question.alternatives { for feature in &mut alt.features { for req in &mut feature.requirements { let Some(value) = req.value.as_deref() else { continue }; let filled = match value { "{user.email}" => user.and_then(|u| u.email.clone()), "{user.name}" => user.map(|u| u.display_name.clone()), _ => continue, }; match filled.filter(|v| !v.trim().is_empty()) { Some(v) => { req.value = Some(v); req.kind = "hidden".to_string(); } None => req.value = None, } } } } } /// An alternative's `action` may carry `{field}` / `{field.sub}` /// placeholders filled from the submitted responses - `/shape/{curve.key}` /// lands on the dynamic page `/shape/[key]` for the key the visitor's /// stroke was given. Returns `None` when a placeholder has no value /// (or a non-scalar one) in `responses`. pub fn template_action(action: &str, responses: &serde_json::Value) -> Option { let mut out = String::new(); let mut rest = action; while let Some(start) = rest.find('{') { out.push_str(&rest[..start]); let end = rest[start..].find('}')? + start; let path = &rest[start + 1..end]; let mut value = responses; for seg in path.split('.') { value = value.get(seg)?; } let scalar = match value { serde_json::Value::String(s) => s.clone(), serde_json::Value::Number(n) => n.to_string(), serde_json::Value::Bool(b) => b.to_string(), _ => return None, }; if scalar.is_empty() { return None; } // A URL segment: keep it to what a segment can carry. let safe: String = scalar .chars() .map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' }) .collect(); out.push_str(&safe); rest = &rest[end + 1..]; } out.push_str(rest); Some(out) } /// 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)] pub struct Responsible { pub name: String, /// A mailto address or plain email - deliberately just a string, /// same as everywhere else content keeps contact info simple. pub contact: String, /// The group that holds the responsibility, when it is a group's /// and not one person's: the board's inbox, not the board's /// secretary. Then the address gets no account of its own (see /// `seed.rs`), and any signed-in member of the group may suggest a /// change to the page. #[serde(default, skip_serializing_if = "Option::is_none")] pub group: Option, } /// Whether to render `question`'s alternatives or its sign-in gate for /// `user`. A rendering hint for the browser, which has no policy: the /// server decides every submit, read, move and listing through /// `access::Policy`, and this must agree with the `open` rule there /// (a page is open to anyone unless `qualifies` names a group). /// Group membership is fixed at login, not re-checked live. pub fn is_qualified(user: Option<&crate::auth::User>, question: &Question) -> bool { match &question.qualifies { None => true, Some(group) => user.is_some_and(|u| u.groups.iter().any(|g| g == group)), } } /// One path through a question: a short pitch, an optional next question /// to advance to on submit, and the form (via `features`) that collects /// what's needed to get there. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Alternative { pub name: String, #[serde(default)] pub description: String, #[serde(default)] pub action: Option, /// Shown but not takeable yet - the button renders disabled and the /// server refuses the submission. For advertising a path before it /// works (lysbue had the same flag). #[serde(default)] pub disabled: bool, #[serde(default)] pub consequence: Vec, #[serde(default)] pub encouragements: Vec, /// Banner image urls, rendered above the description - one renders /// as a plain image, several as a swipeable card deck (Swiper /// Element, vendored in `public/`). Purely decorative, no /// upload/hosting mechanism of their own, just already-hosted urls /// the browser fetches directly (unlike `ResourceSource::Url`, /// never fetched server-side, so none of that variant's SSRF /// concern). #[serde(default)] pub images: Vec, #[serde(default)] pub features: Vec, /// NATS KV bucket to durably store this submission into - just a /// bucket name, never a keyword the runtime special-cases. #[serde(default)] pub record_as: Option, /// A transition fireable by anyone holding one specific item's own /// reference (`?chain=` link) plus a matching `email` - the /// anonymous, single-item counterpart to `ResourceSpec.transitions`' /// group-gated bucket browsing. The email is a second factor /// checked against the stored item, not the lookup key. #[serde(default)] pub self_transition: Option, /// Submitting this alternative invites a person into a Kanidm /// group: the form's `name` and `email` become an account, the /// group membership, and a one-time link to set their own /// credentials (see `people.rs`). Only on a page gated by /// `qualifies`: the inviting group is that page's, the invited /// group is this. The link lands in the runtime's `portal_invites` /// bucket for the inviter's desk, and on `portal.people.invited`. #[serde(default)] pub invite: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Invite { pub group: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SelfTransition { pub bucket: String, pub to: String, pub label: String, /// The states this move may be made from. A person is in dialogue /// with a case for as long as it runs, not only on the day they /// sent it: an applicant written to for a missing drawing answers /// from `incomplete`, and may withdraw from anywhere along the /// way. /// /// Empty means the bucket's own initial state, which is the /// one-shot "confirm" or "unsubscribe" link every one of these was /// before it could say otherwise. Its own initial state, not the /// word `open`: a bucket whose records start at `pending` has a /// confirmation link that fires from `pending`, and reading the /// literal here is what made redoal's watch-confirm button a rule /// the engine would never match. #[serde(default)] pub from: Vec, } impl SelfTransition { /// The states this move may be made from, with the bucket's /// initial state standing in when the content named none. pub fn from_states(&self, initial: &str) -> Vec { if self.from.is_empty() { vec![initial.to_string()] } else { self.from.clone() } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Feature { pub name: String, #[serde(default)] pub description: String, /// Any valid CSS color - set as the feature's `--feature-accent` /// custom property (never interpolated into a stylesheet, so a bad /// value fails to apply instead of injecting CSS). Unset means no /// accent border at all. #[serde(default)] pub color: Option, /// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`), /// rendered via Iconify's public SVG API - no icon library bundled. #[serde(default)] pub icon: Option, #[serde(default)] pub requirements: Vec, /// Live data this feature pulls in. Read-only unless `transitions` /// is non-empty, in which case listed answers get one action button /// per transition (see `src/resource.rs`, `src/answers.rs`). #[serde(default)] pub resource: Option, } /// A live-data read declared in content. The bucket/key are only ever /// resolved server-side from trusted content - a client names a /// question + feature, never a bucket directly, so a visitor can't /// probe arbitrary buckets. No render-mode tag: what a resource /// displays as follows from its data's shape. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ResourceSpec { pub source: ResourceSource, /// A single item; omit to list the whole bucket. Only meaningful /// for a `Kv` source. #[serde(default)] pub key: Option, /// Kanidm group required to read this resource. #[serde(default)] pub requires_group: Option, /// Must be explicitly set for an anonymous-readable resource - a /// spec with neither this nor `requires_group` is unreachable /// (fail closed). Reads only: mutations always require /// `requires_group` regardless of this flag. #[serde(default)] pub public: bool, /// The record states this resource shows, for a `Kv` source; empty /// shows every one. A register that is public by law is public /// about what was decided, not about what was withdrawn or turned /// down, and the desk that works the same bucket is a different /// spec and still sees all of it. #[serde(default)] pub states: Vec, /// Show only the records whose `where.field` holds `where.equals`, /// for a `Kv` source. With a `type: record` field on the other /// side, this is the half that reads: an application's page lists /// the objections that name it, because each objection stored the /// application's id in a field and this asks for exactly those. /// `equals` takes the page's own `{key}` like a resource key does. #[serde(default, rename = "where")] pub filter: Option, /// The moves a listed answer may make, one button each - empty /// means read-only. Server calls are checked against this /// allow-list, so a client can never fire a transition content /// didn't declare. `Kv` sources only. #[serde(default)] pub transitions: Vec, /// A way from each listed record to a page about it: `to` is the /// page, with `{id}` for the record's id and `{field}` for any of /// its fields, and `label` the words on the link. The other half /// of `type: record`, seen from the list: a notice on a board links /// to the page where a neighbour answers it, and that page's form /// carries the notice's id back in. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, /// A jq filter (evaluated via `jaq`, no shell-out) reshaping the /// fetched value before it reaches the frontend - e.g. /// `.[] | {name, url: .html_url}`. `None` returns it as-is. #[serde(default)] pub jq: Option, /// What to say when the resource yields nothing - an empty list, /// or `null` (a `.[] | ...` jq over an empty list produces no /// output at all). Defaults to "Nothing here yet."; content sets /// it where the silence needs a voice ("Nothing released yet"). #[serde(default)] pub empty: Option, } /// Where a resource's live data comes from. `Kv` (a NATS KV bucket /// this server owns) is the only mutable source; the rest are /// read-only live pulls. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ResourceSource { Kv { bucket: String }, GiteaStarred { username: String }, /// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works /// for private repos too (shape the link with jq: a private /// release's html_url 404s for anonymous visitors, so map it to /// null unless the repo is public). GiteaReleases { owner: String, repo: String }, /// Any other HTTPS JSON endpoint. Scheme-restricted and checked /// against loopback/private/link-local addresses at fetch time /// (`resource::fetch_url_resource`) - a server-side fetch of a /// content-supplied URL is SSRF surface, so it fails closed. Url { url: String }, } impl ResourceSpec { /// The KV bucket this resource reads/writes - `None` for a live /// external pull. pub fn bucket(&self) -> Option<&str> { match &self.source { ResourceSource::Kv { bucket } => Some(bucket), ResourceSource::GiteaStarred { .. } | ResourceSource::GiteaReleases { .. } | ResourceSource::Url { .. } => None, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Transition { /// The state a row must be in for this transition's button to /// render and its server call to be accepted. Defaults to "open". #[serde(default = "default_transition_from")] pub from: String, pub to: String, pub label: String, } fn default_transition_from() -> String { crate::answers::OPEN_STATE.to_string() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Requirement { pub name: String, #[serde(default)] pub label: Option, #[serde(default)] pub placeholder: Option, #[serde(default = "default_requirement_type", rename = "type")] pub kind: String, #[serde(default)] pub optional: bool, /// `type: file` - accept multiple files. `type: select` - pick more /// than one option (checkbox-style toggle) instead of exactly one /// (radio-style); the submitted value is a JSON array of ids /// instead of a single id string. #[serde(default)] pub multiple: bool, /// `type: file` only - HTML `accept` hint (UX only, not a security /// boundary - the upload handler re-checks content-type itself). #[serde(default)] pub accept: Option, /// `type: select` only - where the options come from; the same /// `ResourceSpec` mechanism a `Feature.resource` uses. #[serde(default)] pub resource: Option, /// `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, /// `type: select` only - which field in each item is the option's /// stable id. Defaults to trying `_id` then `id`. #[serde(default)] pub id_field: Option, /// Load this field's value from a resource whenever a sibling /// field changes - e.g. a file select populating a textarea with /// the selected file's current content. #[serde(default)] pub bind: Option, /// A preset value the field starts with. On a dynamic page the /// URL segment substitutes into `{name}` placeholders here, like /// resource keys - `value: "{key}"` on a hidden field carries the /// page's key into the answer. `{user.email}` and `{user.name}` /// are the signed-in person's, and a field they fill is carried /// rather than asked (see `fill_from_user`); for a visitor they /// are empty, and the field asks as usual. #[serde(default)] pub value: Option, /// `type: record` only - the bucket the referenced record lives in. /// This is how one case belongs to another: an objection names the /// application it objects to, an appeal the decision it appeals. /// The stored value is the parent's record id, and a submission /// naming a record that is not there is refused, so a reference in /// a record is one that resolved at least once. /// /// A person never types one of these: the id is a chain hash. It /// arrives the way it was given out - `value: "{key}"` on a dynamic /// page, so the link in the letter carries the case it is about. #[serde(default)] pub of: Option, /// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance /// the drawing widget connects to for live echoes of similar /// strokes. Absent means the widget works offline: the drawn path /// still submits, it just never gets a network-computed key or /// echoes. #[serde(default)] pub relay: Option, } /// A field's live data source, parameterized by a sibling field's /// value. When the sibling changes, `resource` is fetched with the /// value as a parameter and the result replaces this field's value /// (an empty sibling never fetches, and never clears an edit). /// "Show the ones whose `field` is `equals`." The whole of what a /// listing may ask about a record's own contents: one field, one /// value, compared as strings. Anything more is a query language, and /// a jq filter is already there for shaping what comes back. /// A strip of pictures across a page (`Question.slideshow`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Slideshow { /// How tall the strip may be, in hundredths of the small viewport /// height (`svh`): 60 is six tenths of the screen. Every picture is /// that tall and as wide as its own shape makes it. #[serde(default = "default_slideshow_height")] pub max_height: u8, pub slides: Vec, } fn default_slideshow_height() -> u8 { 60 } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Slide { /// A path in the content repo (`images/built/01.webp`, served from /// this origin under `/site/`) or an `https://` address. pub image: String, /// Laid over the foot of the picture, and its alternative text. #[serde(default)] pub caption: String, /// The picture's own size in pixels, when known: the browser then /// holds its place in the strip before it has loaded, so pictures /// further along can wait until they are swiped to. #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub height: Option, } impl Slide { /// Where the browser fetches the picture: https as given, anything /// else as a content-repo path under `/site/`. pub fn src(&self) -> String { if self.image.starts_with("https://") || self.image.starts_with("/site/") { self.image.clone() } else { format!("/site/{}", self.image.trim_start_matches('/')) } } } /// A link from every listed record to a page about it (`ResourceSpec.link`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RowLink { pub to: String, pub label: String, } impl RowLink { /// The link for one record: `{id}` is the record's id, `{name}` /// any string field of its responses. A placeholder nothing fills /// stays as it is, which is visible, rather than a wrong page. pub fn href(&self, id: &str, responses: &serde_json::Value) -> String { let mut out = self.to.replace("{id}", id); if let Some(fields) = responses.as_object() { for (k, v) in fields { if let Some(s) = v.as_str() { out = out.replace(&format!("{{{k}}}"), s); } } } out } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FieldIs { pub field: String, pub equals: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Bind { /// The sibling requirement (same alternative) to watch. pub field: String, /// The parameter name the sibling's value is sent as - `{name}` /// templates into a Url source's path, otherwise it's appended as /// a query pair. Defaults to `field`. #[serde(default)] pub param: Option, pub resource: ResourceSpec, } impl Bind { pub fn param_name(&self) -> &str { self.param.as_deref().unwrap_or(&self.field) } } fn default_requirement_type() -> String { "text".to_string() } impl Requirement { pub fn display_label(&self) -> String { self.label.clone().unwrap_or_else(|| self.name.clone()) } } /// Site-wide branding declared by the content repo - `site.yaml` at /// the repo root, sibling of `aggregates.yaml`. An absent file means /// all defaults, which is exactly the historical uhhm look: existing /// deployments change nothing without a content edit. Everything the /// browser needs, so it travels through a server fn (`get_site`). #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct SiteConfig { /// Browser-tab title and wordmark alt text. `None` falls back to /// the compile-time `SITE_NAME`. #[serde(default)] pub title: Option, /// Wordmark image URL (absolute or a path this instance serves). /// `None` falls back to `/wordmark.svg`. #[serde(default)] pub wordmark: Option, /// 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, /// 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. #[serde(default)] pub wordmark_height: Option, /// BCP 47-ish language code for the portal's own chrome strings /// ("Asked by", the nav lead...) and the html lang attribute. /// `None` means "en"; unknown codes fall back to English per key. #[serde(default)] pub lang: Option, /// A content-repo-relative CSS file (e.g. "custom.css") layered /// *after* the default stylesheet, so it overrides. Served /// same-origin at `/site/`; plain path only. #[serde(default)] pub stylesheet: Option, /// A content-repo-relative favicon (svg/png/ico), served at /// `/site/` and used in place of the built-in one. #[serde(default)] pub favicon: Option, #[serde(default)] pub hero: HeroConfig, /// Whether this instance can draw maps: set from `MAPBOX_TOKEN` on /// the way to the browser (see `get_site`), never from content, so /// a `type: location` field knows whether to offer a map or two /// number inputs. #[serde(skip)] pub maps: bool, /// Who this site's mail comes from. Absent means the site sends /// nothing: a state's `mail:` and the invite mail are logged and /// skipped, never half-sent (see `mail.rs`). #[serde(default)] pub mail: Option, } /// The sender identity, and the one built-in mail's wording. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct MailConfig { /// `Name
` or a bare address, on a domain the host's /// mail server may send for. pub from: String, #[serde(default)] pub reply_to: Option, /// Rewords the invite mail; the built-in text is in the site's /// `lang`. `{name}`, `{username}`, `{email}`, `{group}`, `{link}`, /// `{site}`, `{site_name}`, `{expires}`. #[serde(default)] pub invite: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct MailTemplate { pub subject: String, pub body: String, } /// What the landing page's hero is. `plain` is just the copy; `module` /// hands the hero to a JavaScript module the content repo itself /// ships (`module: hero.js`, a repo-relative path portal serves /// same-origin at `/site/`, since Gitea's raw endpoint sends no /// CORS headers). The module exports `mount(container) -> handle` and /// the handle has `stop()`; portal starts it at HTML parse time and /// adopts it on hydration. The piece's look - canvases, SVG, CSS - is /// entirely the module's: portal only reserves the box. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HeroConfig { #[serde(default = "default_hero_kind")] pub kind: String, #[serde(default)] pub module: Option, } impl Default for HeroConfig { fn default() -> Self { Self { kind: default_hero_kind(), module: None } } } fn default_hero_kind() -> String { "plain".to_string() } #[cfg(feature = "ssr")] impl SiteConfig { /// Same policy as `validate_questions`: content declares, the /// runtime refuses what it doesn't understand at load time, so a /// typo'd hero kind is a caught rejection, not a silently plain /// hero. pub fn validate(&self) -> anyhow::Result<()> { match self.hero.kind.as_str() { "plain" => { if self.hero.module.is_some() { anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module"); } } "module" => { let Some(module) = &self.hero.module else { anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)"); }; if !is_safe_site_path(module) { anyhow::bail!( "site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)" ); } } 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(()) } } /// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a /// `Question`, keyed by its own `id`. `repo_url` is the repo's normal /// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the /// Gitea host, owner and repo name are all read from it. Called once at /// 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) } /// 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, action: &str, ) -> Option { 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() } /// An alternative whose action asks nothing of the visitor: no answer /// is recorded, so following it is navigation, not a step they have to /// complete. fn asks_nothing(alt: &Alternative) -> bool { !alt.disabled && alt.record_as.is_none() && alt.self_transition.is_none() } /// The pages a verified answer has earned: where the answered /// alternative sends the visitor next (a templated action resolves to /// its dynamic pattern). When the node predates alternatives being /// indexed, every recording alternative of that question counts - the /// visitor did answer it, only which alternative is unknown. pub fn unlocked_by( questions: &std::collections::HashMap, question_id: &str, alternative: Option<&str>, ) -> Vec { let Some(question) = resolve_question(questions, question_id) else { return Vec::new(); }; question .alternatives .iter() .filter(|a| match alternative { Some(name) => a.name == name, None => a.record_as.is_some(), }) .filter_map(|a| a.action.as_deref()) .filter_map(|action| action_target_id(questions, action)) .collect() } /// The followups a visitor may be offered, given the pages they have /// earned: `seeds` (the page a verified chain sent them to) plus every /// outcome page reachable from a seed or from `current` over /// alternatives that ask nothing - never through one that records an /// answer, since that answer is exactly what they have not given. A /// front door is never in the result and ends the walk, so "back to /// the start" never fans out into the site; standing on a page earns /// nothing by itself beyond what it links to for free. pub fn earned_followups( questions: &std::collections::HashMap, current: &str, seeds: &[String], is_outcome: &dyn Fn(&Question) -> bool, ) -> std::collections::HashSet { use std::collections::{HashSet, VecDeque}; let mut earned: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); for seed in seeds { if let Some(q) = questions.get(seed).filter(|q| is_outcome(q)) { earned.insert(seed.clone()); queue.push_back(q.clone()); } } if let Some(q) = resolve_question(questions, current) { queue.push_back(q); } while let Some(q) = queue.pop_front() { for action in q.alternatives.iter().filter(|a| asks_nothing(a)).filter_map(|a| a.action.as_deref()) { let Some(id) = action_target_id(questions, action) else { continue; }; let target = &questions[&id]; if !is_outcome(target) || earned.contains(&id) || path_matches(&id, current).is_some() { continue; } earned.insert(id.clone()); queue.push_back(target.clone()); } } earned } /// 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); } } }; // A url source's address takes the segment too: // `https://relay.redoal.com/place/{key}`. let substitute_url = |src: &mut ResourceSource| { if let ResourceSource::Url { url } = src { for (name, value) in &captures { *url = url.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); substitute_url(&mut res.source); } for req in &mut feature.requirements { if let Some(res) = &mut req.resource { substitute(&mut res.key); } substitute(&mut req.value); } } } 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> { // The first storyline, once every list of texts has been held to // the site's arity (see `variants.rs`); the machinery reads this // one, and `build_storylines` builds the rest for the visitors. variants::arity(files)?; build_one_storyline(&variants::storyline(files, 0)) } /// Every storyline of the content, each a full map of questions. #[cfg(feature = "ssr")] pub fn build_storylines(files: &[(String, String)]) -> anyhow::Result>> { let arity = variants::arity(files)?; let each = (0..arity).map(|n| build_one_storyline(&variants::storyline(files, n))).collect::>>()?; Ok(variants::Storylines { each }) } #[cfg(feature = "ssr")] fn build_one_storyline( 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(all(test, feature = "ssr"))] mod tests { use super::*; fn schema_fixture() -> std::collections::HashMap { crate::aggregates::parse_aggregates_yaml( r#" aggregates: - bucket: things initial: open states: open: { event: opened } middle: { event: advanced } done: { event: finished } transitions: open: [middle] middle: [done] "#, ) .unwrap() } fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /t name: T alternatives: - name: A features: - name: "" resource: source: {{ kind: kv, bucket: things }} requires_group: owners transitions: {transitions_yaml} "# )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } fn question_with_record(field_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /object name: Object to it alternatives: - name: The register features: - name: All resource: public: true source: {{ kind: kv, bucket: things }} - name: Object record_as: things features: - name: Yours requirements: - name: what {field_yaml} "# )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } // An objection belongs to the application it objects to. The // bucket it points at has to be one this repo declares, or the // reference could never resolve. #[test] fn a_record_can_belong_to_another_record() { let good = question_with_record(" - { name: case, type: record, of: things }"); validate_questions(&good, &schema_fixture()).unwrap(); let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }"); let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("has no aggregate declared"), "{err}"); let unsaid = question_with_record(" - { name: case, type: record }"); let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("must say `of:"), "{err}"); // `of` means one thing, so it belongs to one type. let stray = question_with_record(" - { name: case, of: things }"); let err = validate_questions(&stray, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("only a type: record field has"), "{err}"); // The id arrives on the link; it is not looked up. let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }"); let err = validate_questions(&bound, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("the link it came in on carries the id"), "{err}"); } // A state that grants a group makes an account for someone, so the // form that fills the bucket has to have asked who they are. #[test] fn a_state_that_grants_a_group_needs_a_name_and_an_address_on_file() { let granting = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: things\n initial: open\n states:\n open: { event: opened }\n middle: { event: advanced }\n done: { event: finished, grants: approved_firms }\n transitions:\n open: [middle]\n middle: [done]\n", ) .unwrap(); let asks = question_with_record(" - { name: name }\n - { name: email, type: email }"); validate_questions(&asks, &granting).unwrap(); let no_name = question_with_record(" - { name: email, type: email }"); let err = validate_questions(&no_name, &granting).unwrap_err(); assert!(err.to_string().contains("grant a group"), "{err}"); // The same form is fine against a schema that grants nothing. validate_questions(&no_name, &schema_fixture()).unwrap(); } // A deadline may only make a move a person could have made. #[test] fn a_states_deadline_is_held_to_the_same_graph_as_a_button() { let ok = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions:\n open: [no_answer]\n", ) .unwrap(); assert_eq!(ok["hearings"].after_for("open").unwrap().days, 42); let undeclared = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n", ); assert!(undeclared.unwrap_err().to_string().contains("not a declared transition")); } #[test] fn declared_edge_passes() { let questions = question_with_transitions( " - { from: open, to: middle, label: Advance }\n - { from: middle, to: done, label: Finish }", ); assert!(validate_questions(&questions, &schema_fixture()).is_ok()); } #[test] fn from_defaults_to_open() { let questions = question_with_transitions(" - { to: middle, label: Advance }"); assert!(validate_questions(&questions, &schema_fixture()).is_ok()); } /// A page with one `type: location` field, and whatever else the /// test needs bolted onto the requirement. fn question_with_location(extra_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /t name: Where is it? alternatives: - name: A features: - name: Where requirements: - name: place label: Where type: location optional: true {extra_yaml} "# )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } #[test] fn a_location_field_puts_its_own_value_there() { let questions = question_with_location(""); validate_questions(&questions, &schema_fixture()).unwrap(); // It cannot also be loaded from somewhere. let bound = question_with_location( " bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }", ); let err = validate_questions(&bound, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("the visitor puts the pin"), "{err}"); } #[test] fn a_location_fields_opening_view_is_a_point_on_earth() { let good = question_with_location(" value: \"59.8938,11.0332,15\""); validate_questions(&good, &schema_fixture()).unwrap(); let no_zoom = question_with_location(" value: \"59.8938,11.0332\""); validate_questions(&no_zoom, &schema_fixture()).unwrap(); for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] { let questions = question_with_location(&format!(" value: {bad}")); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("opens the map"), "{bad}: {err}"); } } #[test] fn a_page_about_a_place_says_where_and_how_close() { let place = Place { lat: 59.8938, lon: 11.0332, zoom: 15.0, name: Some("Tomter".into()) }; place.validate().unwrap(); assert_eq!(place.as_value(), "59.8938,11.0332,15"); // The default is a street and its neighbours, not the globe. let bare: Place = serde_yaml::from_str("lat: 59.8938\nlon: 11.0332").unwrap(); assert_eq!(bare.zoom, 15.0); assert!(bare.name.is_none()); for bad in [ Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None }, Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None }, Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None }, Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) }, ] { assert!(bad.validate().is_err(), "{bad:?}"); } } #[test] fn undeclared_from_state_is_rejected() { let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("transition.from")); } #[test] fn undeclared_edge_is_rejected() { // Both states exist, but open -> done skips a step the graph // never declared. let questions = question_with_transitions(" - { from: open, to: done, label: Skip }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("not a declared edge")); } #[test] fn dangling_action_is_rejected() { let question: Question = serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n") .unwrap(); let questions = std::collections::HashMap::from([(question.id.clone(), question)]); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("does not match any declared question id")); } #[test] fn self_referencing_action_passes() { let question: Question = serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /a\n") .unwrap(); let questions = std::collections::HashMap::from([(question.id.clone(), question)]); assert!(validate_questions(&questions, &Default::default()).is_ok()); } fn question_with_bind(bind_field: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /b name: B alternatives: - name: A features: - name: "" requirements: - name: picker type: select resource: public: true source: {{ kind: url, url: "https://x.example/list" }} - name: body type: textarea bind: field: {bind_field} resource: public: true source: {{ kind: url, url: "https://x.example/item/{{picker}}" }} "# )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } #[test] fn bind_to_sibling_passes() { assert!(validate_questions(&question_with_bind("picker"), &Default::default()).is_ok()); } #[test] fn bind_to_missing_sibling_is_rejected() { let err = validate_questions(&question_with_bind("bogus"), &Default::default()).unwrap_err(); assert!(err.to_string().contains("not another requirement")); } #[test] fn bind_to_itself_is_rejected() { let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err(); assert!(err.to_string().contains("not another requirement")); } fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( "id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n" )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } #[test] fn gesture_with_wss_relay_passes() { let questions = question_with_requirement( " - { name: curve, type: gesture, relay: \"wss://relay.redoal.com\", optional: true }", ); assert!(validate_questions(&questions, &Default::default()).is_ok()); } #[test] fn gesture_without_relay_passes_offline() { let questions = question_with_requirement(" - { name: curve, type: gesture, optional: true }"); assert!(validate_questions(&questions, &Default::default()).is_ok()); } #[test] fn relay_on_non_gesture_kind_is_rejected() { let questions = question_with_requirement( " - { name: email, type: email, relay: \"wss://relay.redoal.com\" }", ); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("relay only makes sense on type: gesture")); } #[test] fn https_relay_is_rejected() { let questions = question_with_requirement( " - { name: curve, type: gesture, relay: \"https://relay.redoal.com\" }", ); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("ws:// or wss://")); } #[test] fn site_config_defaults_to_the_plain_hero() { let site = SiteConfig::default(); assert_eq!(site.hero.kind, "plain"); assert!(site.validate().is_ok()); // And an empty file parses to the same thing. let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap(); assert_eq!(parsed, site); } #[test] fn site_config_rejects_unknown_hero_kind() { let site: SiteConfig = serde_yaml::from_str("hero: { kind: fireworks }").unwrap(); let err = site.validate().unwrap_err(); assert!(err.to_string().contains("fireworks")); } #[test] fn site_config_module_hero_needs_a_safe_path() { let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap(); assert!(missing.validate().is_err()); let traversal: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap(); assert!(traversal.validate().is_err()); let absolute: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap(); assert!(absolute.validate().is_err()); let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap(); assert!(ok.validate().is_ok()); } #[test] fn redoal_site_yaml_shape_parses() { let site: SiteConfig = serde_yaml::from_str( "title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n", ) .unwrap(); assert!(site.validate().is_ok()); assert_eq!(site.title.as_deref(), Some("redoal")); assert_eq!(site.hero.module.as_deref(), Some("hero.js")); } #[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 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 record_as: applicants\n - name: subscribe\n action: subscribed\n record_as: subscribers\n - name: draw\n action: /shape/{curve.key}\n record_as: gestures\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 record_as: subscribers\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 record_as: inquiries\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 set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::>(); let earned = |current: &str, seeds: &[&str]| { let seeds: Vec = seeds.iter().map(|s| s.to_string()).collect(); earned_followups(&questions, current, &seeds, &is_outcome) }; // 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); // No chain, no followups: standing on the front door earns none // of the pages its answers lead to, and standing on a followup // one typed in earns nothing either. assert_eq!(earned("/", &[]), set(&[])); assert_eq!(earned("/kontakt", &[]), set(&[])); assert_eq!(earned("/applied", &[]), set(&[])); // A verified answer earns exactly where it sent the visitor. assert_eq!(unlocked_by(&questions, "/", Some("apply")), vec!["/applied"]); assert_eq!(unlocked_by(&questions, "/", Some("draw")), vec!["/shape/[key]"]); assert_eq!(unlocked_by(&questions, "/kontakt", Some("send")), vec!["/takk"]); assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"])); // An old node without the alternative: the answered question's // recording alternatives, and nothing beyond that question. let old: HashSet = unlocked_by(&questions, "/", None).into_iter().collect(); assert_eq!(old, set(&["/applied", "/subscribed", "/shape/[key]"])); assert!(!old.contains("/takk")); // From an earned page, only links that ask nothing carry on: // "watch" records an answer, so /subscribed is not earned by // having drawn a shape; "back" leads to the front door, which // is never a followup and ends the walk. assert_eq!(earned("/shape/abc", &["/shape/[key]"]), set(&["/shape/[key]"])); assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"])); // The email-link page is earned by nothing on the site. assert!(!earned("/", &["/applied"]).contains("/watching")); } #[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 recorded_buckets_must_be_attended() { let write_only = vec![( "index.yaml".to_string(), "name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(), )]; let questions = build_questions(&write_only).unwrap(); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("unattended"), "got: {err}"); // A desk (any Kv resource over the bucket, anywhere in the // repo) attends it. let with_desk = vec![ ( "index.yaml".to_string(), "name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(), ), ( "review/index.yaml".to_string(), "name: Desk\nalternatives:\n - name: Inbox\n features:\n - name: \"\"\n resource:\n requires_group: owners\n source:\n kind: kv\n bucket: inbox\n".to_string(), ), ]; let questions = build_questions(&with_desk).unwrap(); assert!(validate_questions(&questions, &Default::default()).is_ok()); // ..or an explicit attended_by annotation in aggregates.yaml. let aggregates = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: black_hole\n initial: open\n attended_by: n8n nightly digest\n states:\n open: { event: submitted }\n", ) .unwrap(); let questions = build_questions(&write_only).unwrap(); assert!(validate_questions(&questions, &aggregates).is_ok()); } #[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()); } // ── events (announced pages) ───────────────────────────────────────── #[test] fn duration_parses_whole_units() { assert_eq!(parse_duration_ms("3h").unwrap(), 3 * 3_600_000); assert_eq!(parse_duration_ms("90m").unwrap(), 90 * 60_000); assert_eq!(parse_duration_ms("1d 6h").unwrap(), 30 * 3_600_000); assert!(parse_duration_ms("0h").is_err()); assert!(parse_duration_ms("3 hours").is_err()); assert!(parse_duration_ms("").is_err()); } #[test] fn event_window_and_liveness() { let q: Question = serde_yaml::from_str( "id: /opening\nname: Will you be there?\nevent:\n starts: 2026-09-12T18:00:00+02:00\n duration: 3h\n", ) .unwrap(); let (starts, ends) = q.event.as_ref().unwrap().window_ms().unwrap(); assert_eq!(ends - starts, 3 * 3_600_000); assert!(q.event_live_at(starts - 1)); assert!(q.event_live_at(ends - 1)); assert!(!q.event_live_at(ends)); assert!(q.event_ended_at(ends)); assert!(!q.event_ended_at(starts)); let plain: Question = serde_yaml::from_str("id: /x\nname: X\n").unwrap(); assert!(!plain.event_live_at(0) && !plain.event_ended_at(i64::MAX)); } #[test] fn event_validation() { let bad: Question = serde_yaml::from_str( "id: /e\nname: E\nevent:\n starts: next friday\n duration: 3h\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(bad.id.clone(), bad); let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); assert!(err.contains("bad starts"), "{err}"); let dynamic: Question = serde_yaml::from_str( "id: /e/[slot]\nname: E\nevent:\n starts: 2026-09-12T18:00:00Z\n duration: 3h\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(dynamic.id.clone(), dynamic); let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); assert!(err.contains("dynamic page can't carry an event"), "{err}"); } #[test] fn builtin_events_schema_yields_to_content() { let merged = with_builtin_aggregates(Default::default()); let schema = &merged[EVENTS_BUCKET]; assert_eq!(schema.initial, "announced"); assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]); assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]); let content = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n", ) .unwrap(); assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine"); } #[test] fn bucket_is_read_sees_kv_resources() { let qs = question_with_transitions(" - { to: middle, label: Go }"); assert!(bucket_is_read(&qs, "things")); assert!(!bucket_is_read(&qs, EVENTS_BUCKET)); } // ── inline markdown ────────────────────────────────────────────────── #[test] fn markdown_links_and_emphasis() { assert_eq!( render_inline_markdown("See [the programme](https://attac.no/x) *soon*."), "See the programme soon." ); assert_eq!(render_inline_markdown("plain text"), "plain text"); assert_eq!(render_inline_markdown("a\nb"), "a b"); } #[test] fn a_signed_in_person_is_not_asked_who_they_are() { let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n"; let q: Question = serde_yaml::from_str(yaml).unwrap(); let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) }; let mut signed_in = q.clone(); fill_from_user(&mut signed_in, Some(&user)); let reqs = &signed_in.alternatives[0].features[0].requirements; assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no"))); assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N"))); // Mixed text is not a user field, and is left as written. assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says")); // A visitor is asked: the placeholder goes, the field stays. let mut visitor = q.clone(); fill_from_user(&mut visitor, None); let reqs = &visitor.alternatives[0].features[0].requirements; assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None)); assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None)); } #[test] fn a_slide_is_fetched_from_this_origin_unless_it_is_https() { let q: Question = serde_yaml::from_str("name: Q?\nslideshow:\n slides:\n - { image: images/built/01.webp, caption: The canteen, width: 3207, height: 1400 }\n - { image: \"https://cdn.example/a.jpg\" }\n").unwrap(); let show = q.slideshow.unwrap(); assert_eq!(show.max_height, 60); assert_eq!(show.slides[0].src(), "/site/images/built/01.webp"); assert_eq!(show.slides[0].width, Some(3207)); assert_eq!(show.slides[1].src(), "https://cdn.example/a.jpg"); assert_eq!(show.slides[1].caption, ""); } #[test] fn markdown_drops_html_and_unsafe_links() { assert_eq!(render_inline_markdown("x z"), "x y z"); assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad"); assert_eq!( render_inline_markdown("[ring](tel:+4791180485)"), "ring" ); assert_eq!( render_inline_markdown("![site](https://x.no/a.jpg)"), "\"site\"" ); assert_eq!(render_inline_markdown("![x](data:image/png;base64,AA)"), ""); assert_eq!( render_inline_markdown("![local](/images/a.jpg)"), "\"local\"" ); // Image title hints: float + max-width, title dropped. assert_eq!( render_inline_markdown("![J](https://x.no/j.jpg \"right 9rem\")"), "\"J\"" ); assert_eq!( render_inline_markdown("![J](https://x.no/j.jpg \"12\")"), "\"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\")"), "\"J\"" ); assert_eq!(render_inline_markdown("[ok](/shape)"), "ok"); assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "mail"); } #[test] fn templated_action_validates_against_the_dynamic_page() { let landing: Question = serde_yaml::from_str( "id: /\nname: L\nalternatives:\n - name: A\n action: /shape/{curve.key}\n", ) .unwrap(); let place: Question = serde_yaml::from_str("id: /shape/[key]\nname: P\n").unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(landing.id.clone(), landing.clone()); qs.insert(place.id.clone(), place); validate_questions(&qs, &Default::default()).unwrap(); let mut only_landing = std::collections::HashMap::new(); only_landing.insert(landing.id.clone(), landing); assert!(validate_questions(&only_landing, &Default::default()).is_err()); } #[test] fn action_templates_from_responses() { let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3}); assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98")); assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y")); assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape")); assert!(template_action("/shape/{curve.missing}", &r).is_none()); assert!(template_action("/shape/{curve.points}", &r).is_none()); let odd = serde_json::json!({"k": "a/b c"}); assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c")); } #[test] fn dynamic_page_fills_requirement_values() { let q: Question = serde_yaml::from_str( "id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: key\n type: hidden\n value: \"{key}\"\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(q.id.clone(), q); let page = resolve_question(&qs, "/shape/20aa98").unwrap(); assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98")); let q: Question = serde_yaml::from_str( "id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(q.id.clone(), q); let page = resolve_question(&qs, "/shape/20aa98").unwrap(); match &page.alternatives[0].features[0].resource.as_ref().unwrap().source { ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"), _ => panic!(), } } }