main
tag: launch-2026-06-01
tag: magicmirror-v0.1.1
tag: magicmirror-v0.3.0
tag: mnw-cli-v0.1.2
tag: mnw-cli-v0.1.3
tag: mnw-cli-v0.1.4
tag: pom-v0.4.1
tag: pom-v0.4.2
tag: pom-v0.4.3
tag: pom-v0.4.4
tag: pom-v0.4.5
tag: wam-v0.3.0
tag: wam-v0.3.1
Files
Commits
Tags
Notes
Issues
Surface fragment render errors and de-fragilize two UX sinks (ultra-fuzz Run 12 UX)
- Fragment renders no longer swallow to a blank string. Six HTMX handlers did
`.render().unwrap_or_default()`, installing an empty fragment on failure with
no HX-Error — worst on the ssh-keys add/remove paths, which fired a "success"
toast over a vanished list. Add helpers::render_fragment (logs + AppError, so
the error responder returns 500 + HX-Error) and route git_tokens, ssh_keys
(x3), and content_insertions (x2) through it. (The broader render_string
helper has 49 call sites; migrating them to render_fragment is a larger
follow-up.)
- user_media.html no longer interpolates a user filename into an onclick JS
string literal; it uses data-filename + this.dataset.filename, matching the
sibling insertion_list pattern, so safety no longer depends on a distal
sanitizer.
- join_wizard's account step back-nav now threads a real CSRF token instead of
None, so the rendered form can actually submit.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
6 files changed,
+33 insertions,
-33 deletions
49
49
)
50
50
}
51
51
52
+
/// Render an Askama fragment to an HTML string, surfacing a render failure as an
53
+
/// `AppError` (mapped to 500 + an `HX-Error` header by the error responder) instead
54
+
/// of the silent `.render().unwrap_or_default()` blank swap — which installs an
55
+
/// empty HTMX fragment with no error signal (ultra-fuzz Run 12 doubledown S-A). Use
56
+
/// this for HTMX fragment handlers that return `Result`.
57
+
pub fn render_fragment(template: &impl askama::Template) -> crate::error::Result<String> {
58
+
template.render().map_err(|e| {
59
+
tracing::error!(error = ?e, "fragment render failed");
60
+
crate::error::AppError::Internal(anyhow::anyhow!("template render failed"))
61
+
})
62
+
}
63
+
52
64
/// Map a Postgres unique-violation (23505) into a clean `AppError::Conflict(msg)`;
53
65
/// pass any other error through unchanged. A create/insert on a deterministic or
54
66
/// user-supplied unique key that legitimately re-runs (a retry, a duplicate the
1
1
//! Content insertion API: reusable clip library + per-item placement management.
2
2
3
-
use askama::Template;
4
3
use axum::{
5
4
extract::{Path, State},
6
5
response::{Html, IntoResponse},
250
249
})
251
250
.collect();
252
251
253
-
Ok(Html(
254
-
InsertionListTemplate { insertions: display }
255
-
.render()
256
-
.unwrap_or_default(),
257
-
))
252
+
Ok(Html(crate::helpers::render_fragment(
253
+
&InsertionListTemplate { insertions: display },
254
+
)?))
258
255
}
259
256
260
257
/// Rename an insertion clip.
367
364
})
368
365
.collect();
369
366
370
-
Ok(Html(
371
-
crate::templates::PlacementListTemplate {
367
+
Ok(Html(crate::helpers::render_fragment(
368
+
&crate::templates::PlacementListTemplate {
372
369
item_id: item_id.to_string(),
373
370
placements: placement_display,
374
371
available_insertions: insertion_display,
375
-
}
376
-
.render()
377
-
.unwrap_or_default(),
378
-
))
372
+
},
373
+
)?))
379
374
}
380
375
381
376
/// Create a placement (attach an insertion to an item).
111
111
/// Render the tokens list partial, optionally with a freshly-minted plaintext
112
112
/// shown once at the top.
113
113
async fn render_list(state: &AppState, user_id: db::UserId, new_token: Option<&str>) -> Result<String> {
114
-
use askama::Template;
115
114
let tokens = db::git_access_tokens::list_by_user(&state.db, user_id).await?;
116
115
let tokens: Vec<GitTokenView> = tokens.iter().map(GitTokenView::from).collect();
117
-
Ok(crate::templates::GitTokensListTemplate {
116
+
crate::helpers::render_fragment(&crate::templates::GitTokensListTemplate {
118
117
tokens,
119
118
new_token: new_token.map(str::to_string),
120
-
}
121
-
.render()
122
-
.unwrap_or_default())
119
+
})
123
120
}
124
121
125
122
/// View type for token display (never carries the hash or plaintext).
36
36
) -> Result<impl IntoResponse> {
37
37
let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
38
38
let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
39
-
let html = crate::templates::SshKeysListTemplate { ssh_keys }
40
-
.render()
41
-
.unwrap_or_default();
39
+
let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
42
40
Ok(axum::response::Html(html))
43
41
}
44
42
102
100
// Re-render the SSH keys section via HTMX
103
101
let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
104
102
let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
105
-
let html = crate::templates::SshKeysListTemplate { ssh_keys }
106
-
.render()
107
-
.unwrap_or_default();
103
+
let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
108
104
return Ok((
109
105
[("HX-Trigger", hx_toast("SSH key added", "success"))],
110
106
axum::response::Html(html),
142
138
if is_htmx_request(&headers) {
143
139
let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
144
140
let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
145
-
let html = crate::templates::SshKeysListTemplate { ssh_keys }
146
-
.render()
147
-
.unwrap_or_default();
141
+
let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
148
142
return Ok((
149
143
[("HX-Trigger", hx_toast("SSH key removed", "success"))],
150
144
axum::response::Html(html),
182
176
});
183
177
}
184
178
185
-
use askama::Template;
186
-
187
179
/// View type for SSH key display in templates.
188
180
#[derive(Clone)]
189
181
pub struct SshKeyView {
63
63
</div>
64
64
<div class="user-media-card-actions">
65
65
<button class="btn-small user-media-card-btn" onclick="mediaCopyRef('{{ file.markdown_ref }}')">Copy ref</button>
66
-
<button class="btn-small btn-danger user-media-card-btn" onclick="mediaDeleteFile('{{ file.id }}', '{{ file.filename }}')">Delete</button>
66
+
<button class="btn-small btn-danger user-media-card-btn" data-filename="{{ file.filename }}" onclick="mediaDeleteFile('{{ file.id }}', this.dataset.filename)">Delete</button>
67
67
</div>
68
68
</div>
69
69
{% endfor %}
294
294
pub async fn step_load(
295
295
State(state): State<AppState>,
296
296
AuthUser(user): AuthUser,
297
+
session: Session,
297
298
Path(step): Path<String>,
298
299
) -> Result<Response> {
299
-
render_step(&step, &state, user.id).await
300
+
let csrf_token = get_csrf_token(&session).await;
301
+
render_step(&step, &state, user.id, csrf_token).await
300
302
}
301
303
302
304
/// POST `/join/step/{step}`: save and return next step.
322
324
)
323
325
.await?;
324
326
}
325
-
render_step("complete", &state, user.id).await
327
+
render_step("complete", &state, user.id, None).await
326
328
}
327
329
_ => Err(AppError::NotFound),
328
330
}
337
339
}
338
340
339
341
/// Render a step partial with the sidebar nav.
340
-
async fn render_step(step: &str, state: &AppState, user_id: db::UserId) -> Result<Response> {
342
+
async fn render_step(step: &str, state: &AppState, user_id: db::UserId, csrf_token: Option<String>) -> Result<Response> {
341
343
match step {
342
344
"account" => {
345
+
// Thread a real CSRF token so a back-nav to the account step renders a
346
+
// submittable form, not a token-less one that 403s (Run 12 UX MINOR).
343
347
Ok(WizardJoinAccountTemplate {
344
348
nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
345
-
csrf_token: None,
349
+
csrf_token,
346
350
invite_code: None,
347
351
username: String::new(),
348
352
email: String::new(),