5 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug e97470c2f6 Release 0.3.2
Publish release / publish (push) Successful in 1m35s
Test / test (push) Successful in 40s
2026-08-25 20:04:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9f26203ed5 Resource empty text: content-declared, and null counts as empty
Test / test (push) Successful in 25s
A `.[] | {...}` jq over an empty list yields no output, which arrived
as null and rendered nothing - redoal's Releases feature was a bare
heading. Null now reads as the same silence as [], and ResourceSpec
gains `empty:` so content can word it ("Nothing released yet");
default stays "Nothing here yet."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:04:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d6fdcd0bce README: hashed pkg assets in the release flow
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:52:31 +02:00
Bendik Aagaard Lynghaug d65c5593b3 Release 0.3.1
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m25s
2026-08-25 19:48:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 330ab11fe9 Content-hashed pkg assets (hash-files) so stale bundles can't pair with new wasm
Test / test (push) Successful in 26s
An iPhone kept a heuristically-cached portal.js across three releases
(cached before Cache-Control: no-cache existed) and loaded it against
fresh wasm - its snippet imports 404'd, hydration never started, and
the gesture field never mounted. With hash-files = true every pkg
file is content-named and the freshly served page references exactly
its own bundle; hash.txt ships beside the binary (leptos resolves it
next to current_exe), the shell links the stylesheet through
HashedStylesheet, and instances set LEPTOS_HASH_FILES=true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:48:47 +02:00
6 changed files with 37 additions and 7 deletions
+4 -1
View File
@@ -68,11 +68,14 @@ jobs:
stage=$(mktemp -d) stage=$(mktemp -d)
cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal" cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint" cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
# hash-files = true: leptos resolves hash.txt next to the running
# binary, so it ships beside portal in the release dir.
cp "$CARGO_TARGET_DIR/release/hash.txt" "$stage/hash.txt"
# site-root ("target/site" in Cargo.toml) is project-relative, # site-root ("target/site" in Cargo.toml) is project-relative,
# not affected by CARGO_TARGET_DIR - only the plain `cargo build` # not affected by CARGO_TARGET_DIR - only the plain `cargo build`
# outputs (release/, front/) move with that override. # outputs (release/, front/) move with that override.
cp -r target/site "$stage/site" cp -r target/site "$stage/site"
tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint site tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint hash.txt site
rm -rf "$stage" rm -rf "$stage"
# The run's own ephemeral token has write access to this repo - # The run's own ephemeral token has write access to this repo -
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.3.0" version = "0.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+7 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.3.0" version = "0.3.2"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -131,6 +131,12 @@ bin-default-features = false
lib-features = ["hydrate"] lib-features = ["hydrate"]
lib-default-features = false lib-default-features = false
lib-profile-release = "wasm-release" lib-profile-release = "wasm-release"
# Content-hashed pkg names (portal.<hash>.js/wasm/css) + hash.txt: a
# freshly served page can never reference a stale cached bundle - an
# iPhone kept a heuristically-cached portal.js across three releases
# and paired it with new wasm, silently killing hydration. Runtime
# needs LEPTOS_HASH_FILES=true (set in each content repo's deploy env).
hash-files = true
# cargo release <level> is how a portal version is cut: bump, commit, # cargo release <level> is how a portal version is cut: bump, commit,
# tag v{{version}}, push. CI (publish.yml) reacts to the tag and # tag v{{version}}, push. CI (publish.yml) reacts to the tag and
+4 -1
View File
@@ -105,7 +105,10 @@ cargo release patch # or minor / major
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and (`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` + attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
`site/`) to the Gitea release. Plain pushes to `main` only run the `hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo. tests (`test.yml`) — nothing reaches production from this repo.
**Roll it out** (in a content repo): edit one line in that repo's **Roll it out** (in a content repo): edit one line in that repo's
+15 -3
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_meta::{provide_meta_context, HashedStylesheet, MetaTags, Title};
use leptos_router::{ use leptos_router::{
components::{Route, Router, Routes}, components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map}, hooks::{use_location, use_navigate, use_query_map},
@@ -39,6 +39,9 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// once loaded via <link>/<img> on Safari/iOS. // once loaded via <link>/<img> on Safari/iOS.
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/> <link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/> <link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
<AutoReload options=options.clone()/> <AutoReload options=options.clone()/>
<HydrationScripts options/> <HydrationScripts options/>
<MetaTags/> <MetaTags/>
@@ -67,7 +70,6 @@ pub fn App() -> impl IntoView {
provide_context(site); provide_context(site);
view! { view! {
<Stylesheet id="leptos" href="/pkg/portal.css"/>
<Suspense fallback=|| ()> <Suspense fallback=|| ()>
{move || { {move || {
site.get() site.get()
@@ -898,6 +900,7 @@ fn AlternativeCard(
alternative=alt_name.clone() alternative=alt_name.clone()
feature_name=feature_name.clone() feature_name=feature_name.clone()
transitions=spec.transitions.clone() transitions=spec.transitions.clone()
empty=spec.empty.clone()
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
/> />
@@ -1217,6 +1220,7 @@ fn ResourceFeature(
alternative: String, alternative: String,
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView { ) -> impl IntoView {
@@ -1250,6 +1254,7 @@ fn ResourceFeature(
{move || { {move || {
let feature_name = feature_name.clone(); let feature_name = feature_name.clone();
let transitions = transitions.clone(); let transitions = transitions.clone();
let empty = empty.clone().unwrap_or_else(|| "Nothing here yet.".to_string());
data.get() data.get()
.map(|res| match res { .map(|res| match res {
Ok(value) => { Ok(value) => {
@@ -1257,6 +1262,7 @@ fn ResourceFeature(
<ResourceValue <ResourceValue
feature_name=feature_name feature_name=feature_name
transitions=transitions transitions=transitions
empty=empty
value=value value=value
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
@@ -1283,10 +1289,16 @@ fn ResourceFeature(
fn ResourceValue( fn ResourceValue(
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
empty: String,
value: serde_json::Value, value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView { ) -> impl IntoView {
// A jq like `.[] | {...}` over an empty list yields no output at
// all - that arrives as null, and it's the same silence as [].
if value.is_null() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let serde_json::Value::Array(items) = &value { if let serde_json::Value::Array(items) = &value {
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) { if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers; let mut answers = answers;
@@ -1321,7 +1333,7 @@ fn ResourceValue(
.into_any(); .into_any();
} }
if items.is_empty() { if items.is_empty() {
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any(); return view! { <p class="resource-empty">{empty}</p> }.into_any();
} }
// A list of plain objects (e.g. a jq-shaped GiteaStarred/Url // A list of plain objects (e.g. a jq-shaped GiteaStarred/Url
// resource) - render as cards rather than dumping raw JSON. // resource) - render as cards rather than dumping raw JSON.
+6
View File
@@ -202,6 +202,12 @@ pub struct ResourceSpec {
/// `.[] | {name, url: .html_url}`. `None` returns it as-is. /// `.[] | {name, url: .html_url}`. `None` returns it as-is.
#[serde(default)] #[serde(default)]
pub jq: Option<String>, pub jq: Option<String>,
/// 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<String>,
} }
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket /// Where a resource's live data comes from. `Kv` (a NATS KV bucket