init elas atelier

This commit is contained in:
2026-05-14 08:24:41 +02:00
parent 3b704a24a7
commit 38f33cacb1
19 changed files with 1436 additions and 657 deletions
+71 -1
View File
@@ -11,7 +11,7 @@ use crate::{
AppState,
auth::is_authed,
error::AppError,
models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta},
};
const WORDS_PER_MINUTE: u32 = 200;
@@ -114,6 +114,59 @@ fn reading_time(body: &str) -> u32 {
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
}
/// Scan markdown for `![alt](url)` images. Returns (alt, url) pairs in order.
/// Skips inside fenced code blocks. Tolerates titles like `![alt](url "title")`.
fn extract_images(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut in_fence = false;
for line in body.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'!' && bytes[i + 1] == b'[' {
if let Some(rel_close) = line[i + 2..].find(']') {
let close = i + 2 + rel_close;
if close + 1 < line.len() && bytes[close + 1] == b'(' {
if let Some(rel_paren) = line[close + 2..].find(')') {
let paren_end = close + 2 + rel_paren;
let alt = line[i + 2..close].to_string();
let url_field = line[close + 2..paren_end].trim();
let url = url_field
.split_once(|c: char| c.is_whitespace())
.map(|(u, _)| u)
.unwrap_or(url_field)
.trim_matches(|c| c == '<' || c == '>')
.to_string();
if !url.is_empty() {
out.push((alt, url));
}
i = paren_end + 1;
continue;
}
}
}
}
i += 1;
}
}
out
}
fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
images.first().map(|(alt, url)| CoverImage {
url: url.clone(),
alt: alt.clone(),
})
}
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
if let Some(s) = meta.summary.as_ref() {
if !s.trim().is_empty() {
@@ -191,6 +244,13 @@ pub async fn create_post(
}
}
let images = extract_images(&payload.content);
if images.is_empty() {
return Err(AppError::BadRequest(
"A gallery entry must include at least one image (![](url) in the markdown body).".to_string(),
));
}
let meta = PostMeta {
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
@@ -202,6 +262,8 @@ pub async fn create_post(
write_post_atomic(&state, &slug, &contents).await?;
info!("Post saved: {}", slug);
let image_count = images.len() as u32;
let cover = cover_from(&images);
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -211,6 +273,8 @@ pub async fn create_post(
draft: meta.draft,
reading_time: reading_time(&payload.content),
content: payload.content,
cover_image: cover,
image_count,
}))
}
@@ -271,6 +335,7 @@ pub async fn list_posts(
if meta.draft && !admin {
continue;
}
let images = extract_images(&body);
posts.push(PostInfo {
slug: slug.to_string(),
date: meta.date,
@@ -280,6 +345,8 @@ pub async fn list_posts(
draft: meta.draft,
reading_time: reading_time(&body),
excerpt: excerpt_from(&meta, &body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
});
}
}
@@ -305,6 +372,7 @@ pub async fn get_post(
return Err(AppError::NotFound("Post not found".to_string()));
}
let images = extract_images(&body);
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -314,5 +382,7 @@ pub async fn get_post(
draft: meta.draft,
reading_time: reading_time(&body),
content: body,
cover_image: cover_from(&images),
image_count: images.len() as u32,
}))
}