A releases manifest renders as a download card, the device's file first
A kind: url resource whose JSON is a releases manifest - latest, versions newest first with per-platform assets, channels such as a package repo or TestFlight - is one card: the newest version, the file for the visitor's own platform (read from the browser on hydration; the server renders every file), the Linux package repo lines beside a Linux file, TestFlight for iPhones and Macs, and the rest folded under "All downloads". Decided by shape, as every resource is; written for redoal's public releases at dl.redoal.com. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
22d086f9e1
commit
268d545843
@@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest
|
|||||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||||
content repos pin the tag they run (see the end of this file).
|
content repos pin the tag they run (see the end of this file).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest").
|
||||||
|
|
||||||
## 0.6.18 (2026-10-03)
|
## 0.6.18 (2026-10-03)
|
||||||
|
|
||||||
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
|
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ web-sys = { version = "0.3", features = [
|
|||||||
"FileList",
|
"FileList",
|
||||||
"FormData",
|
"FormData",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
|
"Navigator",
|
||||||
"Request",
|
"Request",
|
||||||
"RequestInit",
|
"RequestInit",
|
||||||
"Response",
|
"Response",
|
||||||
|
|||||||
@@ -302,6 +302,18 @@ stays about the work in hand however long the site has run; nothing
|
|||||||
is hidden from a read-only list, and `leaves_after` still takes a
|
is hidden from a read-only list, and `leaves_after` still takes a
|
||||||
record out of every list altogether.
|
record out of every list altogether.
|
||||||
|
|
||||||
|
A **releases manifest** - a `kind: url` resource whose JSON is
|
||||||
|
`{ name, latest, versions: [{ version, date, notes, assets: [{ platform,
|
||||||
|
url, sha256, size }] }], channels: { arch: { repo, package, note },
|
||||||
|
ios: { testflight, note } } }`, newest version first, as a release
|
||||||
|
pipeline writes it - renders as one card: the newest version, the file
|
||||||
|
for the visitor's own platform first (read from the browser: `ios`,
|
||||||
|
`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the
|
||||||
|
other files and the channels folded under "All downloads". Linux gets
|
||||||
|
the package repo lines beside its file; iPhones and Macs get
|
||||||
|
TestFlight. Platforms a manifest lacks fall through to the folded
|
||||||
|
list.
|
||||||
|
|
||||||
`states` and `where` narrow a listing. They matter most for a register
|
`states` and `where` narrow a listing. They matter most for a register
|
||||||
that is public by law — public about what was decided, not about what
|
that is public by law — public about what was decided, not about what
|
||||||
was withdrawn — while the desk working the same bucket is a different
|
was withdrawn — while the desk working the same bucket is a different
|
||||||
|
|||||||
+162
@@ -2124,9 +2124,147 @@ fn ResourceValue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A releases manifest (content-reference, "A releases manifest"):
|
||||||
|
// the newest version, its files per platform, and the standing
|
||||||
|
// channels, with the visitor's own platform picked in the browser.
|
||||||
|
if is_releases_manifest(&value) {
|
||||||
|
return view! { <ReleasesCard manifest=value/> }.into_any();
|
||||||
|
}
|
||||||
|
|
||||||
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
|
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `{ name, latest, versions: [{ version, date, notes, assets: [{
|
||||||
|
/// platform, url, sha256, size }] }], channels: {...} }` - the shape a
|
||||||
|
/// release pipeline writes (redoal's does) for a site to offer downloads.
|
||||||
|
pub fn is_releases_manifest(value: &serde_json::Value) -> bool {
|
||||||
|
value.get("latest").and_then(|v| v.as_str()).is_some()
|
||||||
|
&& value.get("versions").and_then(|v| v.as_array()).is_some_and(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The platform a browser says it is, in the manifest's words:
|
||||||
|
/// `ios`, `macos`, `android`, `windows`, `linux-x86_64`,
|
||||||
|
/// `linux-aarch64`, or `other`. Read, never trusted for anything but
|
||||||
|
/// which download to put first.
|
||||||
|
pub fn platform_of(user_agent: &str) -> &'static str {
|
||||||
|
let ua = user_agent.to_ascii_lowercase();
|
||||||
|
if ua.contains("iphone") || ua.contains("ipad") {
|
||||||
|
"ios"
|
||||||
|
} else if ua.contains("mac os x") || ua.contains("macintosh") {
|
||||||
|
"macos"
|
||||||
|
} else if ua.contains("android") {
|
||||||
|
"android"
|
||||||
|
} else if ua.contains("windows") {
|
||||||
|
"windows"
|
||||||
|
} else if ua.contains("linux") || ua.contains("x11") {
|
||||||
|
if ua.contains("aarch64") || ua.contains("arm64") {
|
||||||
|
"linux-aarch64"
|
||||||
|
} else {
|
||||||
|
"linux-x86_64"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest release as a card: the file for this device first (the
|
||||||
|
/// platform is read in the browser, so the server renders every file
|
||||||
|
/// and the pick lands on hydration), the rest after, and the channels
|
||||||
|
/// that are not files - a package repo, TestFlight.
|
||||||
|
#[component]
|
||||||
|
fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView {
|
||||||
|
let name = manifest.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let latest = manifest.get("latest").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let newest = manifest["versions"][0].clone();
|
||||||
|
let date = newest.get("date").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let notes = newest.get("notes").and_then(|v| v.as_str()).filter(|u| u.starts_with("https://")).map(str::to_string);
|
||||||
|
let assets: Vec<(String, String, String)> = newest
|
||||||
|
.get("assets")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| {
|
||||||
|
let platform = x.get("platform")?.as_str()?.to_string();
|
||||||
|
let url = x.get("url")?.as_str()?.to_string();
|
||||||
|
let sha = x.get("sha256").and_then(|s| s.as_str()).unwrap_or("").to_string();
|
||||||
|
Some((platform, url, sha))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let channels = manifest.get("channels").cloned().unwrap_or(serde_json::Value::Null);
|
||||||
|
let arch = channels.get("arch").cloned();
|
||||||
|
let ios = channels.get("ios").cloned();
|
||||||
|
|
||||||
|
let platform = RwSignal::new(None::<&'static str>);
|
||||||
|
Effect::new(move |_| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
if let Some(ua) = web_sys::window().and_then(|w| w.navigator().user_agent().ok()) {
|
||||||
|
platform.set(Some(platform_of(&ua)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let label = |p: &str| match p {
|
||||||
|
"linux-x86_64" => "Linux (x86_64)",
|
||||||
|
"linux-aarch64" => "Linux (aarch64)",
|
||||||
|
"macos" | "macos-arm64" => "macOS",
|
||||||
|
"windows" | "windows-x86_64" => "Windows",
|
||||||
|
other => other,
|
||||||
|
}.to_string();
|
||||||
|
let assets_for_list = assets.clone();
|
||||||
|
let arch_for_pick = arch.clone();
|
||||||
|
let ios_for_pick = ios.clone();
|
||||||
|
let assets_for_pick = assets.clone();
|
||||||
|
view! {
|
||||||
|
<article class="item-card releases">
|
||||||
|
<div class="item-card-title">
|
||||||
|
<strong>{name.clone()} " " {latest.clone()}</strong>
|
||||||
|
{(!date.is_empty()).then(|| view! { <span class="dim">" · " {date.clone()}</span> })}
|
||||||
|
{notes.clone().map(|u| view! { " · " <a href=u target="_blank" rel="noopener noreferrer">{move || t(&lang().get(), "release_notes")}</a> })}
|
||||||
|
</div>
|
||||||
|
// What this device should take, decided in the browser.
|
||||||
|
{move || {
|
||||||
|
let p = platform.get()?;
|
||||||
|
let arch = arch_for_pick.clone();
|
||||||
|
let ios = ios_for_pick.clone();
|
||||||
|
let own = assets_for_pick.iter().find(|(pl, _, _)| pl == p).cloned();
|
||||||
|
let pick: Option<AnyView> = match (p, own) {
|
||||||
|
(_, Some((pl, url, _))) => Some(view! {
|
||||||
|
<p><a class="pill" href=url download>{t(&lang().get(), "for_this_device")} ": " {label(&pl)}</a></p>
|
||||||
|
{(p.starts_with("linux") && arch.is_some()).then(|| {
|
||||||
|
let a = arch.clone().unwrap();
|
||||||
|
let repo = a.get("repo").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let pkg = a.get("package").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
view! { <pre class="monospace dim">{repo} "\n\npacman -Syu " {pkg}</pre> }
|
||||||
|
})}
|
||||||
|
}.into_any()),
|
||||||
|
("ios" | "macos", None) => ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(str::to_string)).map(|tf| {
|
||||||
|
let note = ios.as_ref().and_then(|i| i.get("note").and_then(|v| v.as_str())).unwrap_or("").to_string();
|
||||||
|
view! { <p><a class="pill" href=tf target="_blank" rel="noopener noreferrer">{t(&lang().get(), "for_this_device")} ": TestFlight"</a> <span class="dim">" " {note}</span></p> }.into_any()
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
pick
|
||||||
|
}}
|
||||||
|
<details class="releases-all">
|
||||||
|
<summary>{move || t(&lang().get(), "all_downloads")}</summary>
|
||||||
|
<ul>
|
||||||
|
{assets_for_list.iter().map(|(pl, url, sha)| view! {
|
||||||
|
<li><a href=url.clone() download>{label(pl)}</a> {(!sha.is_empty()).then(|| view! { <span class="dim monospace">" sha256 " {sha[..sha.len().min(12)].to_string()} "…"</span> })}</li>
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
{arch.as_ref().map(|a| {
|
||||||
|
let note = a.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
view! { <li>{note}</li> }
|
||||||
|
})}
|
||||||
|
{ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(|tf| {
|
||||||
|
let note = i.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
view! { <li><a href=tf.to_string() target="_blank" rel="noopener noreferrer">"TestFlight"</a> " " {note}</li> }
|
||||||
|
}))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pulls the first present, non-null string field out of a JSON object
|
/// Pulls the first present, non-null string field out of a JSON object
|
||||||
/// matching one of `keys`, tried in order - shared between `ItemCard`
|
/// matching one of `keys`, tried in order - shared between `ItemCard`
|
||||||
/// and `SelectField`'s option rendering, since both need "guess a
|
/// and `SelectField`'s option rendering, since both need "guess a
|
||||||
@@ -3365,3 +3503,27 @@ mod picture_tests {
|
|||||||
assert_eq!(picture_of(&answer, None), None);
|
assert_eq!(picture_of(&answer, None), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod releases_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_names_its_platform_in_the_manifests_words() {
|
||||||
|
assert_eq!(platform_of("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)"), "ios");
|
||||||
|
assert_eq!(platform_of("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0)"), "macos");
|
||||||
|
assert_eq!(platform_of("Mozilla/5.0 (X11; Linux x86_64) Firefox"), "linux-x86_64");
|
||||||
|
assert_eq!(platform_of("Mozilla/5.0 (X11; Linux aarch64) Firefox"), "linux-aarch64");
|
||||||
|
assert_eq!(platform_of("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), "windows");
|
||||||
|
assert_eq!(platform_of("Mozilla/5.0 (Linux; Android 15)"), "android");
|
||||||
|
assert_eq!(platform_of("curl/8"), "other");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_manifest_shape_is_a_manifest() {
|
||||||
|
let m = serde_json::json!({"name":"redoal","latest":"0.1.17","versions":[{"version":"0.1.17","assets":[]}]});
|
||||||
|
assert!(is_releases_manifest(&m));
|
||||||
|
assert!(!is_releases_manifest(&serde_json::json!({"latest":"1","versions":[]})));
|
||||||
|
assert!(!is_releases_manifest(&serde_json::json!([{"name":"x"}])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
("en", "sign_out") => "Sign out",
|
("en", "sign_out") => "Sign out",
|
||||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||||
("en", "show_older") => "Show older",
|
("en", "show_older") => "Show older",
|
||||||
|
("en", "for_this_device") => "For this device",
|
||||||
|
("en", "all_downloads") => "All downloads",
|
||||||
|
("en", "release_notes") => "release notes",
|
||||||
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
||||||
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
||||||
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
||||||
@@ -62,6 +65,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
("no", "sign_out") => "Logg ut",
|
("no", "sign_out") => "Logg ut",
|
||||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||||
("no", "show_older") => "Vis eldre",
|
("no", "show_older") => "Vis eldre",
|
||||||
|
("no", "for_this_device") => "Til denne enheten",
|
||||||
|
("no", "all_downloads") => "Alle nedlastinger",
|
||||||
|
("no", "release_notes") => "utgivelsesnotater",
|
||||||
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
||||||
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
||||||
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
||||||
@@ -117,6 +123,9 @@ mod tests {
|
|||||||
"sign_out",
|
"sign_out",
|
||||||
"nothing_here_yet",
|
"nothing_here_yet",
|
||||||
"show_older",
|
"show_older",
|
||||||
|
"for_this_device",
|
||||||
|
"all_downloads",
|
||||||
|
"release_notes",
|
||||||
"upload_failed",
|
"upload_failed",
|
||||||
"upload_too_large",
|
"upload_too_large",
|
||||||
"upload_unavailable",
|
"upload_unavailable",
|
||||||
|
|||||||
@@ -1294,6 +1294,12 @@ textarea:focus {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A releases manifest: the pick for this device, the rest folded. */
|
||||||
|
.releases .pill { display: inline-block; }
|
||||||
|
.releases pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0.4rem 0 0; }
|
||||||
|
.releases-all summary { color: var(--ink-dim); font-size: 0.9rem; cursor: pointer; }
|
||||||
|
.releases-all ul { margin: 0.4rem 0 0 1rem; font-size: 0.9rem; }
|
||||||
|
|
||||||
.show-older:hover {
|
.show-older:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-color: var(--ink-dim);
|
border-color: var(--ink-dim);
|
||||||
|
|||||||
Reference in New Issue
Block a user