diff --git a/docs/content-reference.md b/docs/content-reference.md index 2be83cb..e657d13 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -255,6 +255,7 @@ reads is never a client-supplied parameter. states: [approved, refused] # only these; empty means all where: { field: case, equals: "{key}" } jq: ".[] | {name, url: .html_url}" + limit: 1 # at most this many, after jq empty: Nothing decided yet. link: { to: "/notices/{id}", label: Answer this } # a page per record transitions: # the desk's buttons @@ -264,6 +265,14 @@ reads is never a client-supplied parameter. `source.kind` is one of `kv`, `gitea_starred`, `gitea_releases`, `url`. +`limit: 1` shows only the first item — for a feed where only the newest +matters. A releases card wants the cut someone should run, not every +cut there has ever been, and a page that grows by one card per release +gets longer forever. It applies *after* `jq`, so a filter can reorder +and the limit takes the top of that ordering. A source that yields a +single record has no length and ignores it; `limit: 0` on a list shows +nothing, and the `empty` line speaks instead. + A spec with neither `public: true` nor `requires_group` is unreachable: reads fail closed, and mutations always need a group whatever `public` says. diff --git a/src/content/mod.rs b/src/content/mod.rs index a3a2533..2c72386 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -519,6 +519,18 @@ pub struct ResourceSpec { /// it where the silence needs a voice ("Nothing released yet"). #[serde(default)] pub empty: Option, + /// Show at most this many items, applied after `jq`. For a feed + /// where only the newest matters: a releases card wants the cut you + /// should run, not every cut there has ever been, and a page that + /// grows by one card per release gets longer forever. + /// + /// After `jq` rather than before, so it caps what the page will + /// actually render whatever the filter did — and so content can + /// reorder with jq and still take the top of its own ordering. A + /// source that yields a single record has no length and passes + /// through untouched. `None` shows everything. + #[serde(default)] + pub limit: Option, } /// Where a resource's live data comes from. `Kv` (a NATS KV bucket diff --git a/src/resource.rs b/src/resource.rs index 8e7243c..32b8ae4 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -347,9 +347,29 @@ async fn fetch_resource_value( ResourceSource::Url { url } => fetch_url_json(url, params).await?, }; - match &resource.jq { - Some(filter) => apply_jq(filter, &value), - None => Ok(value), + let shaped = match &resource.jq { + Some(filter) => apply_jq(filter, &value)?, + None => value, + }; + Ok(apply_limit(resource.limit, shaped)) +} + +/// Keep at most `n` items, after `jq` has had its say. +/// +/// Anything that is not a list passes through: a single record has no +/// length to cap, and silently turning one into an empty list would be +/// worse than ignoring a limit that cannot apply. A limit of zero does +/// mean nothing — the resource's `empty` line speaks instead, which is +/// a coherent thing for content to ask for. +#[cfg(feature = "ssr")] +fn apply_limit(limit: Option, value: serde_json::Value) -> serde_json::Value { + let Some(n) = limit else { return value }; + match value { + serde_json::Value::Array(mut items) => { + items.truncate(n); + serde_json::Value::Array(items) + } + other => other, } } @@ -664,6 +684,51 @@ mod tests { assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms)); } + /// `limit` caps what the page renders, and does it after `jq` so + /// content can order with the filter and take the top of its own + /// ordering. The releases card is the case: the newest cut is the + /// one you should run, and a page that grows a card per release + /// gets longer forever. + #[test] + fn limit_caps_a_list_after_the_filter() { + let releases = serde_json::json!([ + {"name": "redoal 0.1.17", "tag_name": "v0.1.17", "body": "newest"}, + {"name": "redoal 0.1.16", "tag_name": "v0.1.16", "body": "older"}, + {"name": "redoal 0.1.15", "tag_name": "v0.1.15", "body": "oldest"} + ]); + // The filter redoal/questions actually declares: bare objects, + // never a wrapped array — the frontend re-wraps. + let filter = ".[] | {name: .name, description: .body, tag: .tag_name, url: null}"; + let shaped = apply_jq(filter, &releases).expect("filter runs"); + + let all = apply_limit(None, shaped.clone()); + assert_eq!(all.as_array().map(Vec::len), Some(3), "no limit shows everything"); + + let latest = apply_limit(Some(1), shaped.clone()); + let rows = latest.as_array().expect("still a list"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["tag"], "v0.1.17", "and it is the newest, which Gitea returns first"); + + let two = apply_limit(Some(2), shaped.clone()); + assert_eq!(two.as_array().map(Vec::len), Some(2)); + + // Asking for more than there is is not an error. + let plenty = apply_limit(Some(99), shaped); + assert_eq!(plenty.as_array().map(Vec::len), Some(3)); + } + + /// A limit that cannot apply is ignored rather than guessed at. + #[test] + fn limit_leaves_a_single_record_alone() { + let one = serde_json::json!({"name": "a record", "state": "open"}); + assert_eq!(apply_limit(Some(1), one.clone()), one); + assert_eq!(apply_limit(Some(0), one.clone()), one, "no length to cap"); + + // Zero on a list does mean nothing — the `empty` line speaks. + let list = serde_json::json!([{"a": 1}, {"a": 2}]); + assert_eq!(apply_limit(Some(0), list).as_array().map(Vec::len), Some(0)); + } + /// A canned, Gitea-API-shaped fixture - the same fields the real /// `/users/{username}/starred` endpoint returns (Gitea 1.27: /// `stars_count`, not GitHub's `stargazers_count`; `website` is ""