content: split by concern, every path still content::x
content.rs was 2,373 lines doing five jobs. It is now a directory: mod.rs keeps the types and the resolution rules (router, refs, reachability, build_questions) and the tests; gitea.rs loads from Gitea and watches for reload, through crate::http; validate.rs holds the checks a repo must pass; markdown.rs the inline markdown; handlers.rs the Axum routes. Each submodule is re-exported from content, so no caller changes. The loader module is server-only and gated as such. No behaviour change; 65 unit tests unchanged and green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c91bf59946
commit
f909657c14
@@ -0,0 +1,290 @@
|
||||
//! What a content repo must satisfy before it serves: every action
|
||||
//! lands on a page, every transition is one the state machine allows,
|
||||
//! every recorded bucket is read back somewhere, and the built-in
|
||||
//! aggregates the runtime owns are always present.
|
||||
|
||||
#[allow(unused_imports)] // the browser build sees fewer items
|
||||
use super::*;
|
||||
|
||||
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
|
||||
/// hero and `Requirement.relay` validation.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
|
||||
let parsed = url::Url::parse(relay)
|
||||
.map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?;
|
||||
if !matches!(parsed.scheme(), "ws" | "wss") {
|
||||
anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates every declared transition target (`SelfTransition.to`,
|
||||
/// `ResourceSpec.transitions[].to`) against `aggregates` - the real
|
||||
/// state graph loaded from `aggregates.yaml` - for every bucket that
|
||||
/// has one declared. A bucket with no entry in `aggregates` is left
|
||||
/// alone entirely (no validation applied): not every bucket needs to
|
||||
/// be event-sourced to keep working. Called on every content
|
||||
/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the
|
||||
/// standalone `question-lint` binary, so a YAML typo becomes a caught,
|
||||
/// logged rejection instead of a silently-accepted, later-broken
|
||||
/// string.
|
||||
/// Bucket the runtime keeps for announced pages (`Question.event`):
|
||||
/// one record per event question, `announced` while the window is
|
||||
/// open, `awaiting_summary` once it closes - the "post what happened"
|
||||
/// task a desk reading this bucket picks up - and `summarized` when an
|
||||
/// owner marks it done. Declared here, not in content, because the
|
||||
/// runtime's sweeper (`announce.rs`) is what moves the records.
|
||||
pub const EVENTS_BUCKET: &str = "portal_events";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
const BUILTIN_AGGREGATES_YAML: &str = "\
|
||||
aggregates:
|
||||
- bucket: portal_events
|
||||
initial: announced
|
||||
states:
|
||||
announced: { event: announced }
|
||||
awaiting_summary: { event: ended }
|
||||
summarized: { event: summarized }
|
||||
transitions:
|
||||
announced: [awaiting_summary, summarized]
|
||||
awaiting_summary: [summarized]
|
||||
";
|
||||
|
||||
/// Adds the runtime's own state graphs to a content repo's. Content
|
||||
/// wins if it declares the same bucket (say, to add states).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn with_builtin_aggregates(
|
||||
mut aggregates: std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||
) -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
|
||||
let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML)
|
||||
.expect("builtin aggregates yaml is valid");
|
||||
for (bucket, schema) in builtin {
|
||||
aggregates.entry(bucket).or_insert(schema);
|
||||
}
|
||||
aggregates
|
||||
}
|
||||
|
||||
/// True when some kv resource in the content reads `bucket` - the
|
||||
/// attended-bucket check, exposed so the lint can warn about the
|
||||
/// runtime's own `portal_events` (a warning, not a failure: a repo
|
||||
/// with no event pages has nothing to attend).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn bucket_is_read(questions: &std::collections::HashMap<String, Question>, bucket: &str) -> bool {
|
||||
questions.values().any(|q| {
|
||||
q.alternatives.iter().any(|a| {
|
||||
a.features
|
||||
.iter()
|
||||
.any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn validate_questions(
|
||||
questions: &std::collections::HashMap<String, Question>,
|
||||
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||
) -> anyhow::Result<()> {
|
||||
for question in questions.values() {
|
||||
if let Some(event) = &question.event {
|
||||
if question.is_dynamic() {
|
||||
anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id);
|
||||
}
|
||||
if let Err(e) = event.window_ms() {
|
||||
anyhow::bail!("question {:?}: event: {e}", question.id);
|
||||
}
|
||||
}
|
||||
if let Some(target) = &question.requires_chain {
|
||||
if !questions.contains_key(target) {
|
||||
anyhow::bail!(
|
||||
"question {:?}: requires_chain {:?} does not match any declared question id",
|
||||
question.id,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
for alternative in &question.alternatives {
|
||||
// A dangling action is a literal dead end: the submit
|
||||
// button navigates to "Nothing here".
|
||||
if let Some(action) = &alternative.action {
|
||||
if action.contains('{') {
|
||||
// Placeholders are filled at submit time; the
|
||||
// shape must land on a dynamic page once filled.
|
||||
let probe = template_action(action, &serde_json::json!({}));
|
||||
let filled = {
|
||||
let mut a = action.clone();
|
||||
while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) {
|
||||
a.replace_range(i..=j, "x");
|
||||
}
|
||||
a
|
||||
};
|
||||
let _ = probe;
|
||||
// Match the pattern itself: resolve_question would
|
||||
// hand back a clone whose id is already concrete.
|
||||
let lands = questions
|
||||
.values()
|
||||
.any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some());
|
||||
if lands {
|
||||
continue;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)",
|
||||
question.id, alternative.name, action
|
||||
);
|
||||
}
|
||||
match questions.get(action) {
|
||||
None => anyhow::bail!(
|
||||
"question {:?} alternative {:?}: action {:?} does not match any declared question id",
|
||||
question.id, alternative.name, action
|
||||
),
|
||||
// A dynamic page needs a concrete segment value to
|
||||
// be a URL - a submit button can't supply one.
|
||||
Some(target) if target.is_dynamic() => anyhow::bail!(
|
||||
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question",
|
||||
question.id, alternative.name, action
|
||||
),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if let Some(st) = &alternative.self_transition {
|
||||
if let Some(schema) = aggregates.get(&st.bucket) {
|
||||
if !schema.has_state(&st.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bind targets resolve across the whole alternative, same
|
||||
// scope as the signal maps the renderer builds.
|
||||
let sibling_names: std::collections::HashSet<&str> = alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| f.requirements.iter())
|
||||
.map(|r| r.name.as_str())
|
||||
.collect();
|
||||
for feature in &alternative.features {
|
||||
for requirement in &feature.requirements {
|
||||
if requirement.kind == "select"
|
||||
&& requirement.resource.is_none()
|
||||
&& requirement.options.is_empty()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if let Some(relay) = &requirement.relay {
|
||||
if requirement.kind != "gesture" && requirement.kind != "voice" {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice",
|
||||
question.id, alternative.name, feature.name, requirement.name, requirement.kind
|
||||
);
|
||||
}
|
||||
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
))?;
|
||||
}
|
||||
if requirement.kind == "gesture"
|
||||
&& (requirement.resource.is_some() || requirement.bind.is_some())
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if let Some(bind) = &requirement.bind {
|
||||
if bind.field == requirement.name
|
||||
|| !sibling_names.contains(bind.field.as_str())
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative",
|
||||
question.id, alternative.name, feature.name, requirement.name, bind.field
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(resource) = &feature.resource else {
|
||||
continue;
|
||||
};
|
||||
let Some(bucket) = resource.bucket() else {
|
||||
continue;
|
||||
};
|
||||
let Some(schema) = aggregates.get(bucket) else {
|
||||
continue;
|
||||
};
|
||||
for transition in &resource.transitions {
|
||||
if !schema.has_state(&transition.from) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.from, bucket
|
||||
);
|
||||
}
|
||||
if !schema.has_state(&transition.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.to, bucket
|
||||
);
|
||||
}
|
||||
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attended buckets: no publicly collected answer may land somewhere
|
||||
// nothing reads. Every `record_as` bucket must either be read back
|
||||
// by some Kv resource in this same content repo (a desk or listing)
|
||||
// or carry an explicit `attended_by:` annotation in aggregates.yaml
|
||||
// naming the automation that consumes it. This is a contract, not a
|
||||
// convention, because convention already failed once: a question
|
||||
// was dropped and its bucket - answers included - silently fell out
|
||||
// of every reader's view.
|
||||
let mut read_buckets = std::collections::HashSet::new();
|
||||
let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet<String>| {
|
||||
if let ResourceSource::Kv { bucket } = &spec.source {
|
||||
set.insert(bucket.clone());
|
||||
}
|
||||
};
|
||||
for question in questions.values() {
|
||||
for alternative in &question.alternatives {
|
||||
for feature in &alternative.features {
|
||||
if let Some(spec) = &feature.resource {
|
||||
note_resource(spec, &mut read_buckets);
|
||||
}
|
||||
for requirement in &feature.requirements {
|
||||
if let Some(spec) = &requirement.resource {
|
||||
note_resource(spec, &mut read_buckets);
|
||||
}
|
||||
if let Some(bind) = &requirement.bind {
|
||||
note_resource(&bind.resource, &mut read_buckets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for question in questions.values() {
|
||||
for alternative in &question.alternatives {
|
||||
if let Some(bucket) = &alternative.record_as {
|
||||
let attended = read_buckets.contains(bucket)
|
||||
|| aggregates
|
||||
.get(bucket)
|
||||
.is_some_and(|schema| schema.attended_by.is_some());
|
||||
if !attended {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.",
|
||||
question.id, alternative.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user