style: format rust code
This commit is contained in:
parent
71ac08c373
commit
294d60afab
@ -4,11 +4,11 @@ use dioxus::prelude::*;
|
||||
#[cfg(feature = "server")]
|
||||
use http::header::{HeaderValue, SET_COOKIE};
|
||||
|
||||
use crate::auth::{password, session};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::auth::session::get_session_from_ctx;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::auth::session::get_session_from_ctx;
|
||||
use crate::auth::{password, session};
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::models::user::{PublicUser, User, UserRole};
|
||||
|
||||
@ -106,7 +106,8 @@ pub async fn register(
|
||||
});
|
||||
}
|
||||
|
||||
let password_hash = password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?;
|
||||
let password_hash =
|
||||
password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?;
|
||||
|
||||
let result = client
|
||||
.query_one(
|
||||
@ -176,7 +177,8 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
|
||||
};
|
||||
|
||||
let password_hash: String = row.get("password_hash");
|
||||
let valid = password::verify_password(&password, &password_hash).map_err(|_| AppError::Internal("密码处理失败"))?;
|
||||
let valid = password::verify_password(&password, &password_hash)
|
||||
.map_err(|_| AppError::Internal("密码处理失败"))?;
|
||||
|
||||
if !valid {
|
||||
return Ok(AuthResponse {
|
||||
|
||||
@ -10,8 +10,8 @@ pub struct PendingStatusItem {
|
||||
pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem>, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
if ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
@ -35,7 +35,10 @@ pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem
|
||||
let result: Vec<PendingStatusItem> = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let status = found.get(&id).cloned().unwrap_or_else(|| "gone".to_string());
|
||||
let status = found
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "gone".to_string());
|
||||
PendingStatusItem { id, status }
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[server(CreateComment, "/api")]
|
||||
pub async fn create_comment(
|
||||
@ -12,13 +12,13 @@ pub async fn create_comment(
|
||||
) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::comments::helpers::{
|
||||
compute_content_hash, validate_comment_content, validate_comment_email,
|
||||
validate_comment_name, validate_comment_url,
|
||||
};
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::comments::helpers::{
|
||||
validate_comment_name, validate_comment_email, validate_comment_url,
|
||||
validate_comment_content, compute_content_hash,
|
||||
};
|
||||
|
||||
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||
let parts = ctx.parts_mut();
|
||||
@ -177,12 +177,7 @@ pub async fn create_comment(
|
||||
}
|
||||
}
|
||||
|
||||
let content_hash = compute_content_hash(
|
||||
post_id,
|
||||
parent_id,
|
||||
&author_name,
|
||||
&content_md,
|
||||
);
|
||||
let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md);
|
||||
|
||||
let dup: Option<i64> = client
|
||||
.query_opt(
|
||||
@ -215,7 +210,8 @@ pub async fn create_comment(
|
||||
|
||||
let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||
let parts = ctx.parts_mut();
|
||||
parts.headers
|
||||
parts
|
||||
.headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
@ -236,7 +232,10 @@ pub async fn create_comment(
|
||||
&depth,
|
||||
&author_name.trim(),
|
||||
&author_email.trim(),
|
||||
&author_url.as_ref().map(|u| u.trim()).filter(|u| !u.is_empty()),
|
||||
&author_url
|
||||
.as_ref()
|
||||
.map(|u| u.trim())
|
||||
.filter(|u| !u.is_empty()),
|
||||
&content_md,
|
||||
&content_html,
|
||||
&content_hash,
|
||||
|
||||
@ -89,8 +89,7 @@ pub fn validate_comment_name(name: &str) -> Result<(), String> {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn validate_comment_email(email: &str) -> Result<(), String> {
|
||||
let re =
|
||||
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
||||
let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
||||
if !re.is_match(email.trim()) {
|
||||
return Err("邮箱格式不正确".to_string());
|
||||
}
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[server(GetPendingComments, "/api")]
|
||||
pub async fn get_pending_comments(
|
||||
page: i32,
|
||||
) -> Result<PendingCommentsResponse, ServerFnError> {
|
||||
pub async fn get_pending_comments(page: i32) -> Result<PendingCommentsResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::comments::helpers::row_to_admin_comment;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::comments::helpers::row_to_admin_comment;
|
||||
use crate::api::error::AppError;
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
@ -54,10 +52,10 @@ pub async fn get_pending_comments(
|
||||
pub async fn get_pending_count() -> Result<PendingCountResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
@ -91,10 +89,10 @@ pub async fn get_all_comments(
|
||||
) -> Result<AllCommentsResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::comments::helpers::row_to_admin_comment;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::comments::helpers::row_to_admin_comment;
|
||||
use crate::api::error::AppError;
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
|
||||
@ -36,9 +36,7 @@ pub fn render_comment_markdown(md: &str) -> String {
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
in_codeblock = true;
|
||||
code_lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
|
||||
Some(lang.to_string())
|
||||
}
|
||||
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
code_buffer.clear();
|
||||
@ -83,10 +81,19 @@ mod tests {
|
||||
#[test]
|
||||
fn render_comment_heading_all_levels() {
|
||||
for md in &[
|
||||
"# H1", "## H2", "### H3", "#### H4", "##### H5", "###### H6",
|
||||
"# H1",
|
||||
"## H2",
|
||||
"### H3",
|
||||
"#### H4",
|
||||
"##### H5",
|
||||
"###### H6",
|
||||
] {
|
||||
let result = render_comment_markdown(md);
|
||||
assert!(result.contains("<strong>"), "heading not converted for: {}", md);
|
||||
assert!(
|
||||
result.contains("<strong>"),
|
||||
"heading not converted for: {}",
|
||||
md
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,7 +169,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn clean_comment_html_removes_details_summary() {
|
||||
let result = clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
|
||||
let result =
|
||||
clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
|
||||
assert!(!result.contains("details"));
|
||||
assert!(!result.contains("summary"));
|
||||
}
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)]
|
||||
#![allow(
|
||||
clippy::unused_unit,
|
||||
deprecated,
|
||||
unused_imports,
|
||||
clippy::too_many_arguments
|
||||
)]
|
||||
|
||||
mod types;
|
||||
mod helpers;
|
||||
mod markdown;
|
||||
mod create;
|
||||
mod read;
|
||||
mod update;
|
||||
mod list;
|
||||
mod check;
|
||||
mod create;
|
||||
mod helpers;
|
||||
mod list;
|
||||
mod markdown;
|
||||
mod read;
|
||||
mod types;
|
||||
mod update;
|
||||
|
||||
pub use types::*;
|
||||
pub use create::create_comment;
|
||||
pub use read::{get_comments, get_comment_count};
|
||||
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
|
||||
pub use list::{get_pending_comments, get_pending_count, get_all_comments};
|
||||
pub use check::check_pending_status;
|
||||
pub use create::create_comment;
|
||||
pub use list::{get_all_comments, get_pending_comments, get_pending_count};
|
||||
pub use read::{get_comment_count, get_comments};
|
||||
pub use types::*;
|
||||
pub use update::{approve_comment, batch_update_comment_status, spam_comment, trash_comment};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use markdown::render_comment_markdown;
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[server(GetComments, "/api")]
|
||||
pub async fn get_comments(
|
||||
post_id: i32,
|
||||
) -> Result<CommentTreeResponse, ServerFnError> {
|
||||
pub async fn get_comments(post_id: i32) -> Result<CommentTreeResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::comments::helpers::row_to_public_comment;
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
if let Some(cached) = cache::get_comments_by_post(post_id).await {
|
||||
let count = cached.len() as i64;
|
||||
@ -45,14 +43,12 @@ pub async fn get_comments(
|
||||
}
|
||||
|
||||
#[server(GetCommentCount, "/api")]
|
||||
pub async fn get_comment_count(
|
||||
post_id: i32,
|
||||
) -> Result<CommentCountResponse, ServerFnError> {
|
||||
pub async fn get_comment_count(post_id: i32) -> Result<CommentCountResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
|
||||
if let Some(cached) = cache::get_comment_count(post_id).await {
|
||||
return Ok(CommentCountResponse { count: cached });
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::comment::{AdminComment, PublicComment};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::api::comments::types::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[server(ApproveComment, "/api")]
|
||||
pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
@ -79,10 +79,10 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
|
||||
pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
@ -132,10 +132,10 @@ pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
@ -185,10 +185,10 @@ pub async fn batch_update_comment_status(
|
||||
) -> Result<BatchStatusResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
|
||||
|
||||
@ -63,7 +63,10 @@ mod tests {
|
||||
!msg.contains("5432"),
|
||||
"should not leak internal details: {msg}"
|
||||
);
|
||||
assert!(msg.contains("服务暂时不可用"), "expected generic message: {msg}");
|
||||
assert!(
|
||||
msg.contains("服务暂时不可用"),
|
||||
"expected generic message: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
118
src/api/image.rs
118
src/api/image.rs
@ -266,7 +266,10 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
|
||||
tracing::warn!("Failed to create cache dir: {:?}", e);
|
||||
return;
|
||||
}
|
||||
let ct_str = cached.content_type.to_str().unwrap_or("application/octet-stream");
|
||||
let ct_str = cached
|
||||
.content_type
|
||||
.to_str()
|
||||
.unwrap_or("application/octet-stream");
|
||||
if let Err(e) = tokio::fs::write(format!("{}.dat", base), &cached.data).await {
|
||||
tracing::warn!("Failed to write disk cache data: {:?}", e);
|
||||
}
|
||||
@ -319,10 +322,15 @@ pub async fn serve_image(
|
||||
}
|
||||
|
||||
if let Some(cached) = read_disk_cache(&cache_key).await {
|
||||
let _ = IMAGE_CACHE.insert(cache_key.clone(), CachedImage {
|
||||
data: cached.data.clone(),
|
||||
content_type: cached.content_type.clone(),
|
||||
}).await;
|
||||
let _ = IMAGE_CACHE
|
||||
.insert(
|
||||
cache_key.clone(),
|
||||
CachedImage {
|
||||
data: cached.data.clone(),
|
||||
content_type: cached.content_type.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, cached.content_type)],
|
||||
@ -388,77 +396,113 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_valid_width() {
|
||||
let params = ImageParams { w: Some(100), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
w: Some(100),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_zero_width_rejected() {
|
||||
let params = ImageParams { w: Some(0), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
w: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_oversized_width_rejected() {
|
||||
let params = ImageParams { w: Some(5000), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
w: Some(5000),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_valid_rotation() {
|
||||
for angle in [0, 90, 180, 270] {
|
||||
let params = ImageParams { rotate: Some(angle), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
rotate: Some(angle),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_ok(), "angle {} should be valid", angle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_invalid_rotation_rejected() {
|
||||
let params = ImageParams { rotate: Some(45), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
rotate: Some(45),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_valid_format() {
|
||||
for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] {
|
||||
let params = ImageParams { format: Some(fmt.to_string()), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
format: Some(fmt.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_ok(), "format {} should be valid", fmt);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_invalid_format_rejected() {
|
||||
let params = ImageParams { format: Some("gif".to_string()), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
format: Some("gif".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_valid_thumbnail() {
|
||||
let params = ImageParams { thumb: Some("200x150".to_string()), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
thumb: Some("200x150".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_invalid_thumbnail_rejected() {
|
||||
let params = ImageParams { thumb: Some("200".to_string()), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
thumb: Some("200".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_valid_quality() {
|
||||
let params = ImageParams { quality: Some(85), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
quality: Some(85),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_zero_quality_rejected() {
|
||||
let params = ImageParams { quality: Some(0), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
quality: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_params_validate_over_100_quality_rejected() {
|
||||
let params = ImageParams { quality: Some(101), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
quality: Some(101),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
@ -486,9 +530,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detect_format_jpeg() {
|
||||
assert!(matches!(detect_format("photo.jpg"), image::ImageFormat::Jpeg));
|
||||
assert!(matches!(detect_format("photo.jpeg"), image::ImageFormat::Jpeg));
|
||||
assert!(matches!(detect_format("PHOTO.JPG"), image::ImageFormat::Jpeg));
|
||||
assert!(matches!(
|
||||
detect_format("photo.jpg"),
|
||||
image::ImageFormat::Jpeg
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_format("photo.jpeg"),
|
||||
image::ImageFormat::Jpeg
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_format("PHOTO.JPG"),
|
||||
image::ImageFormat::Jpeg
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -498,18 +551,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detect_format_webp() {
|
||||
assert!(matches!(detect_format("anim.webp"), image::ImageFormat::WebP));
|
||||
assert!(matches!(
|
||||
detect_format("anim.webp"),
|
||||
image::ImageFormat::WebP
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_defaults_to_jpeg() {
|
||||
assert!(matches!(detect_format("file.xyz"), image::ImageFormat::Jpeg));
|
||||
assert!(matches!(
|
||||
detect_format("file.xyz"),
|
||||
image::ImageFormat::Jpeg
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_differs_for_different_params() {
|
||||
let p1 = ImageParams { w: Some(100), ..Default::default() };
|
||||
let p2 = ImageParams { w: Some(200), ..Default::default() };
|
||||
let p1 = ImageParams {
|
||||
w: Some(100),
|
||||
..Default::default()
|
||||
};
|
||||
let p2 = ImageParams {
|
||||
w: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg"));
|
||||
}
|
||||
|
||||
@ -521,7 +586,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn is_empty_false_when_any_set() {
|
||||
let params = ImageParams { w: Some(100), ..Default::default() };
|
||||
let params = ImageParams {
|
||||
w: Some(100),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!params.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@ -204,9 +204,7 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
||||
let clean_text = clean_html(text);
|
||||
html.push_str(&format!(
|
||||
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
|
||||
id,
|
||||
clean_text,
|
||||
clean_text
|
||||
id, clean_text, clean_text
|
||||
));
|
||||
}
|
||||
|
||||
@ -367,7 +365,11 @@ mod tests {
|
||||
#[test]
|
||||
fn render_markdown_data_uri_image() {
|
||||
let result = render_markdown_enhanced("");
|
||||
assert!(result.html.contains("data:image/svg+xml"), "data URI should be preserved in img src, got: {}", result.html);
|
||||
assert!(
|
||||
result.html.contains("data:image/svg+xml"),
|
||||
"data URI should be preserved in img src, got: {}",
|
||||
result.html
|
||||
);
|
||||
assert!(result.html.contains("alt=\"alt\""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,10 @@ pub async fn list_published_posts(
|
||||
{
|
||||
let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page };
|
||||
if let Some((cached_posts, cached_total)) = crate::cache::get_post_list(&cache_key).await {
|
||||
return Ok(PostListResponse { posts: cached_posts, total: cached_total });
|
||||
return Ok(PostListResponse {
|
||||
posts: cached_posts,
|
||||
total: cached_total,
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
@ -68,15 +71,15 @@ pub async fn list_published_posts(
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(PostListResponse { posts: Vec::new(), total: 0 })
|
||||
Ok(PostListResponse {
|
||||
posts: Vec::new(),
|
||||
total: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[server(ListPosts, "/api")]
|
||||
pub async fn list_posts(
|
||||
page: i32,
|
||||
per_page: i32,
|
||||
) -> Result<PostListResponse, ServerFnError> {
|
||||
pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
@ -84,10 +87,7 @@ pub async fn list_posts(
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let count_row = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL",
|
||||
&[],
|
||||
)
|
||||
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let total: i64 = count_row.get(0);
|
||||
@ -122,7 +122,10 @@ pub async fn list_posts(
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(PostListResponse { posts: Vec::new(), total: 0 })
|
||||
Ok(PostListResponse {
|
||||
posts: Vec::new(),
|
||||
total: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,8 +133,12 @@ pub async fn list_posts(
|
||||
pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await {
|
||||
return Ok(PostListResponse { posts: cached_posts, total: cached_total });
|
||||
if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await
|
||||
{
|
||||
return Ok(PostListResponse {
|
||||
posts: cached_posts,
|
||||
total: cached_total,
|
||||
});
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
@ -170,6 +177,9 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(PostListResponse { posts: Vec::new(), total: 0 })
|
||||
Ok(PostListResponse {
|
||||
posts: Vec::new(),
|
||||
total: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +1,32 @@
|
||||
#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)]
|
||||
#![allow(
|
||||
clippy::unused_unit,
|
||||
deprecated,
|
||||
unused_imports,
|
||||
clippy::too_many_arguments
|
||||
)]
|
||||
|
||||
mod types;
|
||||
mod helpers;
|
||||
mod create;
|
||||
mod update;
|
||||
mod delete;
|
||||
mod read;
|
||||
mod helpers;
|
||||
mod list;
|
||||
mod search;
|
||||
mod tags;
|
||||
mod stats;
|
||||
mod read;
|
||||
mod rebuild;
|
||||
mod search;
|
||||
mod stats;
|
||||
mod tags;
|
||||
mod types;
|
||||
mod update;
|
||||
|
||||
pub use types::*;
|
||||
pub use create::create_post;
|
||||
pub use update::update_post;
|
||||
pub use delete::delete_post;
|
||||
pub use list::{get_posts_by_tag, list_posts, list_published_posts};
|
||||
pub use read::{get_post_by_id, get_post_by_slug};
|
||||
pub use list::{list_published_posts, list_posts, get_posts_by_tag};
|
||||
pub use search::search_posts;
|
||||
pub use tags::list_tags;
|
||||
pub use stats::get_post_stats;
|
||||
pub use rebuild::rebuild_content_html;
|
||||
pub use search::search_posts;
|
||||
pub use stats::get_post_stats;
|
||||
pub use tags::list_tags;
|
||||
pub use types::*;
|
||||
pub use update::update_post;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use crate::api::markdown::render_markdown_enhanced;
|
||||
|
||||
@ -15,7 +15,10 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
|
||||
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(PostListResponse { posts: Vec::new(), total: 0 });
|
||||
return Ok(PostListResponse {
|
||||
posts: Vec::new(),
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let rows = client
|
||||
@ -49,6 +52,9 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(PostListResponse { posts: Vec::new(), total: 0 })
|
||||
Ok(PostListResponse {
|
||||
posts: Vec::new(),
|
||||
total: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use super::types::TagListResponse;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::models::post::Tag;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::models::post::Tag;
|
||||
|
||||
#[server(ListTags, "/api")]
|
||||
pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {
|
||||
|
||||
@ -65,7 +65,8 @@ pub async fn update_post(
|
||||
_ => crate::api::slug::slugify(&title),
|
||||
};
|
||||
|
||||
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?;
|
||||
let final_slug =
|
||||
crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?;
|
||||
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||
let content_html = rendered.html;
|
||||
let toc_html = if rendered.toc_html.is_empty() {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::LazyLock;
|
||||
#[cfg(feature = "server")]
|
||||
use std::num::NonZeroU32;
|
||||
use axum::http::StatusCode;
|
||||
#[cfg(feature = "server")]
|
||||
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
|
||||
#[cfg(feature = "server")]
|
||||
use axum::http::StatusCode;
|
||||
use std::num::NonZeroU32;
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::LazyLock;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn env_or(key: &str, default: u32) -> NonZeroU32 {
|
||||
@ -90,10 +90,7 @@ fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<St
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn get_client_ip_with_trusted(
|
||||
headers: &http::HeaderMap,
|
||||
trusted_proxy_count: usize,
|
||||
) -> String {
|
||||
pub fn get_client_ip_with_trusted(headers: &http::HeaderMap, trusted_proxy_count: usize) -> String {
|
||||
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(ip) = ip_from_x_forwarded_for(value, trusted_proxy_count) {
|
||||
return ip;
|
||||
@ -204,7 +201,10 @@ mod tests {
|
||||
#[test]
|
||||
fn get_client_ip_ignores_empty_x_forwarded_for_entries() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", " , 1.2.3.4 , 5.6.7.8 , ".parse().unwrap());
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
" , 1.2.3.4 , 5.6.7.8 , ".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_client_ip_with_trusted(&headers, 1), "1.2.3.4");
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,15 +7,81 @@ use std::collections::HashSet;
|
||||
fn default_allowed_tags() -> HashSet<&'static str> {
|
||||
let mut set = HashSet::new();
|
||||
for tag in [
|
||||
"a", "abbr", "acronym", "area", "article", "aside", "b", "bdi",
|
||||
"bdo", "blockquote", "br", "caption", "center", "cite", "code",
|
||||
"col", "colgroup", "data", "dd", "del", "details", "dfn", "div",
|
||||
"dl", "dt", "em", "figcaption", "figure", "footer", "h1", "h2",
|
||||
"h3", "h4", "h5", "h6", "header", "hgroup", "hr", "i", "img",
|
||||
"ins", "kbd", "li", "map", "mark", "nav", "ol", "p", "pre",
|
||||
"q", "rp", "rt", "rtc", "ruby", "s", "samp", "small", "span",
|
||||
"strike", "strong", "sub", "summary", "sup", "table", "tbody",
|
||||
"td", "th", "thead", "time", "tr", "tt", "u", "ul", "var", "wbr",
|
||||
"a",
|
||||
"abbr",
|
||||
"acronym",
|
||||
"area",
|
||||
"article",
|
||||
"aside",
|
||||
"b",
|
||||
"bdi",
|
||||
"bdo",
|
||||
"blockquote",
|
||||
"br",
|
||||
"caption",
|
||||
"center",
|
||||
"cite",
|
||||
"code",
|
||||
"col",
|
||||
"colgroup",
|
||||
"data",
|
||||
"dd",
|
||||
"del",
|
||||
"details",
|
||||
"dfn",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"header",
|
||||
"hgroup",
|
||||
"hr",
|
||||
"i",
|
||||
"img",
|
||||
"ins",
|
||||
"kbd",
|
||||
"li",
|
||||
"map",
|
||||
"mark",
|
||||
"nav",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"q",
|
||||
"rp",
|
||||
"rt",
|
||||
"rtc",
|
||||
"ruby",
|
||||
"s",
|
||||
"samp",
|
||||
"small",
|
||||
"span",
|
||||
"strike",
|
||||
"strong",
|
||||
"sub",
|
||||
"summary",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"time",
|
||||
"tr",
|
||||
"tt",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
"wbr",
|
||||
] {
|
||||
set.insert(tag);
|
||||
}
|
||||
@ -34,10 +100,31 @@ fn clean_content_tags() -> HashSet<&'static str> {
|
||||
fn default_allowed_schemes() -> HashSet<&'static str> {
|
||||
let mut set = HashSet::new();
|
||||
for scheme in [
|
||||
"bitcoin", "ftp", "ftps", "geo", "http", "https", "im", "irc",
|
||||
"ircs", "magnet", "mailto", "mms", "mx", "news", "nntp",
|
||||
"openpgp4fpr", "sip", "sms", "smsto", "ssh", "tel", "url",
|
||||
"webcal", "wtai", "xmpp",
|
||||
"bitcoin",
|
||||
"ftp",
|
||||
"ftps",
|
||||
"geo",
|
||||
"http",
|
||||
"https",
|
||||
"im",
|
||||
"irc",
|
||||
"ircs",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"mms",
|
||||
"mx",
|
||||
"news",
|
||||
"nntp",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sms",
|
||||
"smsto",
|
||||
"ssh",
|
||||
"tel",
|
||||
"url",
|
||||
"webcal",
|
||||
"wtai",
|
||||
"xmpp",
|
||||
] {
|
||||
set.insert(scheme);
|
||||
}
|
||||
@ -109,9 +196,17 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
||||
("q", vec!["cite"]),
|
||||
("table", vec!["align", "char", "charoff", "summary"]),
|
||||
("tbody", vec!["align", "char", "charoff"]),
|
||||
("td", vec!["align", "char", "charoff", "colspan", "headers", "rowspan"]),
|
||||
(
|
||||
"td",
|
||||
vec!["align", "char", "charoff", "colspan", "headers", "rowspan"],
|
||||
),
|
||||
("tfoot", vec!["align", "char", "charoff"]),
|
||||
("th", vec!["align", "char", "charoff", "colspan", "headers", "rowspan", "scope"]),
|
||||
(
|
||||
"th",
|
||||
vec![
|
||||
"align", "char", "charoff", "colspan", "headers", "rowspan", "scope",
|
||||
],
|
||||
),
|
||||
("thead", vec!["align", "char", "charoff"]),
|
||||
("tr", vec!["align", "char", "charoff"]),
|
||||
];
|
||||
|
||||
@ -261,7 +261,11 @@ mod tests {
|
||||
let uuid = "abc-123";
|
||||
let ext = "jpg";
|
||||
let file_name = format!("{}.{}.{}", now_str, uuid, ext);
|
||||
assert!(!file_name.contains(' '), "filename should not contain spaces: got '{}'", file_name);
|
||||
assert!(
|
||||
!file_name.contains(' '),
|
||||
"filename should not contain spaces: got '{}'",
|
||||
file_name
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -297,5 +301,3 @@ mod tests {
|
||||
assert!(loaded.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -36,8 +36,7 @@ fn main() {
|
||||
output.push_str(&mocha_rewritten);
|
||||
|
||||
std::fs::create_dir_all("public").expect("Failed to create public/");
|
||||
std::fs::write("public/highlight.css", output)
|
||||
.expect("Failed to write public/highlight.css");
|
||||
std::fs::write("public/highlight.css", output).expect("Failed to write public/highlight.css");
|
||||
|
||||
println!("Generated public/highlight.css");
|
||||
}
|
||||
|
||||
65
src/cache.rs
65
src/cache.rs
@ -49,8 +49,6 @@ pub enum CacheKey {
|
||||
PendingCommentCount,
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Cache Instances
|
||||
// ============================================================================
|
||||
@ -153,12 +151,17 @@ pub async fn set_post_list(key: &CacheKey, posts: Vec<Post>, total: i64) {
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_total_published_posts() -> Option<i64> {
|
||||
POST_LIST_CACHE.get(&CacheKey::TotalPublishedPosts).await.map(|(_, total)| total)
|
||||
POST_LIST_CACHE
|
||||
.get(&CacheKey::TotalPublishedPosts)
|
||||
.await
|
||||
.map(|(_, total)| total)
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn set_total_published_posts(total: i64) {
|
||||
let _ = POST_LIST_CACHE.insert(CacheKey::TotalPublishedPosts, (vec![], total)).await;
|
||||
let _ = POST_LIST_CACHE
|
||||
.insert(CacheKey::TotalPublishedPosts, (vec![], total))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
@ -330,21 +333,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cache_key_equality() {
|
||||
let k1 = CacheKey::PublishedPosts { page: 1, per_page: 10 };
|
||||
let k2 = CacheKey::PublishedPosts { page: 1, per_page: 10 };
|
||||
let k3 = CacheKey::PublishedPosts { page: 2, per_page: 10 };
|
||||
let k1 = CacheKey::PublishedPosts {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
};
|
||||
let k2 = CacheKey::PublishedPosts {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
};
|
||||
let k3 = CacheKey::PublishedPosts {
|
||||
page: 2,
|
||||
per_page: 10,
|
||||
};
|
||||
assert_eq!(k1, k2);
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_list_cache_roundtrip() {
|
||||
let key = CacheKey::PublishedPosts { page: 999, per_page: 99 };
|
||||
let key = CacheKey::PublishedPosts {
|
||||
page: 999,
|
||||
per_page: 99,
|
||||
};
|
||||
let posts = vec![];
|
||||
|
||||
|
||||
set_post_list(&key, posts.clone(), 0).await;
|
||||
let cached = get_post_list(&key).await;
|
||||
|
||||
|
||||
assert!(cached.is_some());
|
||||
let (cached_posts, cached_total) = cached.unwrap();
|
||||
assert_eq!(cached_posts.len(), 0);
|
||||
@ -353,11 +368,15 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn tag_list_cache_roundtrip() {
|
||||
let tags = vec![Tag { id: 1, name: "rust".to_string(), post_count: 5 }];
|
||||
|
||||
let tags = vec![Tag {
|
||||
id: 1,
|
||||
name: "rust".to_string(),
|
||||
post_count: 5,
|
||||
}];
|
||||
|
||||
set_tag_list(tags.clone()).await;
|
||||
let cached = get_tag_list().await;
|
||||
|
||||
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap()[0].name, "rust");
|
||||
}
|
||||
@ -384,21 +403,25 @@ mod tests {
|
||||
prev_post: None,
|
||||
next_post: None,
|
||||
});
|
||||
|
||||
|
||||
set_post_by_slug("test", post.clone()).await;
|
||||
let cached = get_post_by_slug("test").await;
|
||||
|
||||
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap().unwrap().title, "Test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_stats_cache_roundtrip() {
|
||||
let stats = PostStats { total: 10, drafts: 3, published: 7 };
|
||||
|
||||
let stats = PostStats {
|
||||
total: 10,
|
||||
drafts: 3,
|
||||
published: 7,
|
||||
};
|
||||
|
||||
set_post_stats(stats.clone()).await;
|
||||
let cached = get_post_stats().await;
|
||||
|
||||
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap().total, 10);
|
||||
}
|
||||
@ -425,13 +448,13 @@ mod tests {
|
||||
prev_post: None,
|
||||
next_post: None,
|
||||
});
|
||||
|
||||
|
||||
set_post_by_slug("invalidation-test", post.clone()).await;
|
||||
let cached_before = get_post_by_slug("invalidation-test").await;
|
||||
assert!(cached_before.is_some());
|
||||
|
||||
|
||||
invalidate_post_by_slug("invalidation-test").await;
|
||||
|
||||
|
||||
let cached_after = get_post_by_slug("invalidation-test").await;
|
||||
assert!(cached_after.is_none());
|
||||
}
|
||||
|
||||
@ -73,7 +73,8 @@ pub fn AdminLayout() -> Element {
|
||||
}
|
||||
};
|
||||
|
||||
let is_write_route = matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
|
||||
let is_write_route =
|
||||
matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
|
||||
let main_class = if is_write_route {
|
||||
"flex-1 w-full max-w-5xl mx-auto px-6 flex flex-col overflow-hidden"
|
||||
} else {
|
||||
|
||||
@ -38,5 +38,3 @@ pub fn AdminDashboardSkeleton() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::api::comments::create_comment;
|
||||
use crate::components::comments::section::CommentContext;
|
||||
use crate::components::forms::{INPUT_CLASS, BUTTON_PRIMARY_CLASS, AlertBox};
|
||||
use crate::components::forms::{AlertBox, BUTTON_PRIMARY_CLASS, INPUT_CLASS};
|
||||
use crate::hooks::comment_storage::{self, PendingComment};
|
||||
|
||||
#[component]
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::components::comments::section::CommentContext;
|
||||
use crate::components::comments::form::CommentForm;
|
||||
use crate::components::comments::section::CommentContext;
|
||||
use crate::models::comment::PublicComment;
|
||||
|
||||
#[component]
|
||||
pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::hooks::comment_storage::PendingComment;
|
||||
use crate::components::comments::item::CommentItem;
|
||||
use crate::components::comments::pending_item::PendingCommentItem;
|
||||
use crate::hooks::comment_storage::PendingComment;
|
||||
use crate::models::comment::PublicComment;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MergedComment {
|
||||
@ -23,10 +23,13 @@ fn merge_and_treeify(
|
||||
.chain(pending.into_iter().map(MergedComment::Pending))
|
||||
.collect();
|
||||
|
||||
let all_ids: HashSet<i64> = all.iter().map(|c| match c {
|
||||
MergedComment::Approved(c) => c.id,
|
||||
MergedComment::Pending(c) => c.id,
|
||||
}).collect();
|
||||
let all_ids: HashSet<i64> = all
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
MergedComment::Approved(c) => c.id,
|
||||
MergedComment::Pending(c) => c.id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut children_map: HashMap<Option<i64>, Vec<MergedComment>> = HashMap::new();
|
||||
for comment in all {
|
||||
@ -38,7 +41,10 @@ fn merge_and_treeify(
|
||||
Some(pid) if !all_ids.contains(&pid) => None,
|
||||
_ => parent_id,
|
||||
};
|
||||
children_map.entry(effective_parent).or_default().push(comment);
|
||||
children_map
|
||||
.entry(effective_parent)
|
||||
.or_default()
|
||||
.push(comment);
|
||||
}
|
||||
|
||||
for children in children_map.values_mut() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
pub mod section;
|
||||
pub mod form;
|
||||
pub mod list;
|
||||
pub mod item;
|
||||
pub mod pending_item;
|
||||
pub mod actions;
|
||||
pub mod form;
|
||||
pub mod item;
|
||||
pub mod list;
|
||||
pub mod pending_item;
|
||||
pub mod section;
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::hooks::comment_storage::{PendingComment, render_pending_content};
|
||||
use crate::hooks::comment_storage::{render_pending_content, PendingComment};
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element {
|
||||
|
||||
let depth = if comment.parent_id.is_none() && comment.depth > 0 {
|
||||
0
|
||||
} else {
|
||||
|
||||
@ -13,7 +13,11 @@ pub fn FormInput(
|
||||
oninput: EventHandler<String>,
|
||||
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||
) -> Element {
|
||||
let disabled_class = if disabled { "opacity-60 cursor-not-allowed" } else { "" };
|
||||
let disabled_class = if disabled {
|
||||
"opacity-60 cursor-not-allowed"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
rsx! {
|
||||
input {
|
||||
class: "{INPUT_CLASS} {disabled_class}",
|
||||
@ -43,8 +47,14 @@ pub fn FormLabel(label: &'static str) -> Element {
|
||||
#[component]
|
||||
pub fn AlertBox(message: String, variant: &'static str) -> Element {
|
||||
let (bg_class, text_class) = match variant {
|
||||
"error" => ("bg-red-100 dark:bg-red-900/30", "text-red-700 dark:text-red-300"),
|
||||
"success" => ("bg-green-100 dark:bg-green-900/30", "text-green-700 dark:text-green-300"),
|
||||
"error" => (
|
||||
"bg-red-100 dark:bg-red-900/30",
|
||||
"text-red-700 dark:text-red-300",
|
||||
),
|
||||
"success" => (
|
||||
"bg-green-100 dark:bg-green-900/30",
|
||||
"text-green-700 dark:text-green-300",
|
||||
),
|
||||
_ => ("bg-paper-code-bg", "text-paper-secondary"),
|
||||
};
|
||||
rsx! {
|
||||
|
||||
@ -3,17 +3,18 @@ use dioxus::prelude::*;
|
||||
#[component]
|
||||
pub fn ImageViewer(
|
||||
src: String,
|
||||
#[props(default = "?w=800".to_string())]
|
||||
thumb_params: String,
|
||||
#[props(default = "图片".to_string())]
|
||||
alt: String,
|
||||
#[props(default = false)]
|
||||
lazy_load: bool,
|
||||
#[props(default = "?w=800".to_string())] thumb_params: String,
|
||||
#[props(default = "图片".to_string())] alt: String,
|
||||
#[props(default = false)] lazy_load: bool,
|
||||
) -> Element {
|
||||
let mut is_open = use_signal(|| false);
|
||||
|
||||
let thumb_src = if src.contains('?') {
|
||||
format!("{}&{}", src.split('?').next().unwrap_or(&src), thumb_params.trim_start_matches('?'))
|
||||
format!(
|
||||
"{}&{}",
|
||||
src.split('?').next().unwrap_or(&src),
|
||||
thumb_params.trim_start_matches('?')
|
||||
)
|
||||
} else {
|
||||
format!("{}{}", src, thumb_params)
|
||||
};
|
||||
|
||||
@ -9,7 +9,7 @@ pub fn PostHeader(post: Post) -> Element {
|
||||
rsx! {
|
||||
header { class: "post-header",
|
||||
Breadcrumbs { title: post.title.clone() }
|
||||
|
||||
|
||||
h1 { class: "post-title",
|
||||
"{post.title}"
|
||||
if post.status == PostStatus::Draft {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::components::skeletons::atoms::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// 归档页骨架屏
|
||||
/// 结构:统计行("共 N 篇文章") + 年份标题 + 月份标题 + 文章条目列表
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::components::skeletons::atoms::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn CommentListSkeleton() -> Element {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::utils::time::sleep_ms;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// 骨架屏 pulse 动画延迟(毫秒)
|
||||
/// 加载时间低于此值时骨架屏只显示静态灰色块,避免 pulse 动画一闪而过
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
pub mod atoms;
|
||||
pub mod archive_skeleton;
|
||||
pub mod atoms;
|
||||
pub mod comment_skeleton;
|
||||
pub mod delayed_skeleton;
|
||||
pub mod home_skeleton;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::components::skeletons::atoms::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// 文章详情页骨架屏
|
||||
/// 结构:面包屑 + 标题(h1) + 摘要 + 元信息 + 封面图 + 正文(多段) + Footer
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::components::skeletons::atoms::*;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn WriteSkeleton() -> Element {
|
||||
|
||||
@ -16,10 +16,12 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
||||
let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg);
|
||||
|
||||
Pool::builder(mgr)
|
||||
.max_size(std::env::var("DB_POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20))
|
||||
.max_size(
|
||||
std::env::var("DB_POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20),
|
||||
)
|
||||
.build()
|
||||
.expect("Failed to create database connection pool")
|
||||
});
|
||||
@ -34,7 +36,12 @@ pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::
|
||||
Ok(conn) => return Ok(conn),
|
||||
Err(e) => {
|
||||
if attempt < MAX_RETRIES {
|
||||
tracing::warn!("DB connection attempt {} failed, retrying in {:?}: {:?}", attempt + 1, RETRY_DELAY, e);
|
||||
tracing::warn!(
|
||||
"DB connection attempt {} failed, retrying in {:?}: {:?}",
|
||||
attempt + 1,
|
||||
RETRY_DELAY,
|
||||
e
|
||||
);
|
||||
tokio::time::sleep(RETRY_DELAY).await;
|
||||
}
|
||||
last_err = Some(e);
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
pub mod server {
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use syntect::html::{ClassedHTMLGenerator, ClassStyle};
|
||||
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::utils::time::sleep_ms;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// 骨架屏最小显示延迟(毫秒)。加载时间低于此值时不会显示骨架屏,避免闪烁。
|
||||
pub const MIN_SKELETON_DELAY_MS: u32 = 200;
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
pub mod delayed_loading;
|
||||
pub mod comment_storage;
|
||||
pub mod delayed_loading;
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@ -1,6 +1,6 @@
|
||||
mod api;
|
||||
mod cache;
|
||||
mod auth;
|
||||
mod cache;
|
||||
mod components;
|
||||
mod context;
|
||||
mod db;
|
||||
@ -44,13 +44,14 @@ fn main() {
|
||||
});
|
||||
|
||||
let config = ServeConfig::builder().incremental(
|
||||
dioxus::server::IncrementalRendererConfig::default()
|
||||
.invalidate_after(std::time::Duration::from_secs(
|
||||
dioxus::server::IncrementalRendererConfig::default().invalidate_after(
|
||||
std::time::Duration::from_secs(
|
||||
std::env::var("SSR_CACHE_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3600),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
let api_routes = axum::Router::new().route(
|
||||
"/api/upload",
|
||||
@ -66,9 +67,7 @@ fn main() {
|
||||
let dioxus_app =
|
||||
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
||||
|
||||
let router = api_routes
|
||||
.merge(static_routes)
|
||||
.merge(dioxus_app);
|
||||
let router = api_routes.merge(static_routes).merge(dioxus_app);
|
||||
|
||||
Ok(router)
|
||||
});
|
||||
|
||||
@ -71,7 +71,9 @@ impl Post {
|
||||
|
||||
pub fn status_badge_class(&self) -> &'static str {
|
||||
match self.status {
|
||||
PostStatus::Published => "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300",
|
||||
PostStatus::Published => {
|
||||
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||
}
|
||||
PostStatus::Draft => "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]",
|
||||
}
|
||||
}
|
||||
@ -128,7 +130,10 @@ mod tests {
|
||||
#[test]
|
||||
fn post_status_from_str() {
|
||||
assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
|
||||
assert_eq!(PostStatus::from_str("published"), Some(PostStatus::Published));
|
||||
assert_eq!(
|
||||
PostStatus::from_str("published"),
|
||||
Some(PostStatus::Published)
|
||||
);
|
||||
assert_eq!(PostStatus::from_str("unknown"), None);
|
||||
assert_eq!(PostStatus::from_str(""), None);
|
||||
}
|
||||
@ -186,6 +191,9 @@ mod tests {
|
||||
#[test]
|
||||
fn post_status_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&PostStatus::Draft).unwrap();
|
||||
assert_eq!(serde_json::from_str::<PostStatus>(&json).unwrap(), PostStatus::Draft);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PostStatus>(&json).unwrap(),
|
||||
PostStatus::Draft
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,6 +95,9 @@ mod tests {
|
||||
#[test]
|
||||
fn user_role_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&UserRole::Admin).unwrap();
|
||||
assert_eq!(serde_json::from_str::<UserRole>(&json).unwrap(), UserRole::Admin);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<UserRole>(&json).unwrap(),
|
||||
UserRole::Admin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,13 +3,11 @@ use std::collections::HashSet;
|
||||
use dioxus::prelude::*;
|
||||
use dioxus::router::components::Link;
|
||||
|
||||
use crate::api::comments::{
|
||||
approve_comment, batch_update_comment_status, spam_comment,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::comments::{get_all_comments, AllCommentsResponse};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::comments::trash_comment;
|
||||
use crate::api::comments::{approve_comment, batch_update_comment_status, spam_comment};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::comments::{get_all_comments, AllCommentsResponse};
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::models::comment::{AdminComment, CommentStatus};
|
||||
use crate::router::Route;
|
||||
@ -54,7 +52,11 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
||||
#[allow(unused_variables)]
|
||||
let filter_status = move || {
|
||||
let f = active_filter();
|
||||
if f.is_empty() { None } else { Some(f) }
|
||||
if f.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(f)
|
||||
}
|
||||
};
|
||||
|
||||
// 客户端(CSR)加载数据
|
||||
@ -313,14 +315,29 @@ fn CommentRow(
|
||||
on_trash: EventHandler,
|
||||
) -> Element {
|
||||
let (badge_class, status_label) = match &comment.status {
|
||||
CommentStatus::Pending => ("bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", "待审核"),
|
||||
CommentStatus::Approved => ("bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", "已通过"),
|
||||
CommentStatus::Spam => ("bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", "垃圾"),
|
||||
CommentStatus::Trash => ("bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400", "已删除"),
|
||||
CommentStatus::Pending => (
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||
"待审核",
|
||||
),
|
||||
CommentStatus::Approved => (
|
||||
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
"已通过",
|
||||
),
|
||||
CommentStatus::Spam => (
|
||||
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
"垃圾",
|
||||
),
|
||||
CommentStatus::Trash => (
|
||||
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
|
||||
"已删除",
|
||||
),
|
||||
};
|
||||
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
|
||||
let preview = if comment.content_md.len() > 100 {
|
||||
format!("{}...", &comment.content_md[..comment.content_md.ceil_char_boundary(100)])
|
||||
format!(
|
||||
"{}...",
|
||||
&comment.content_md[..comment.content_md.ceil_char_boundary(100)]
|
||||
)
|
||||
} else {
|
||||
comment.content_md.clone()
|
||||
};
|
||||
@ -411,9 +428,13 @@ fn CommentsPagination(current_page: i32, total: i64) -> Element {
|
||||
let prev_route = if current_page - 1 <= 1 {
|
||||
Route::AdminComments {}
|
||||
} else {
|
||||
Route::AdminCommentsPage { page: current_page - 1 }
|
||||
Route::AdminCommentsPage {
|
||||
page: current_page - 1,
|
||||
}
|
||||
};
|
||||
let next_route = Route::AdminCommentsPage {
|
||||
page: current_page + 1,
|
||||
};
|
||||
let next_route = Route::AdminCommentsPage { page: current_page + 1 };
|
||||
|
||||
rsx! {
|
||||
nav { class: "flex mt-6 justify-between",
|
||||
|
||||
@ -37,7 +37,10 @@ pub fn PostsPage(page: i32) -> Element {
|
||||
let p = current_page;
|
||||
spawn(async move {
|
||||
match list_posts(p, POSTS_PER_PAGE).await {
|
||||
Ok(PostListResponse { posts: list, total: t }) => {
|
||||
Ok(PostListResponse {
|
||||
posts: list,
|
||||
total: t,
|
||||
}) => {
|
||||
posts.set(list);
|
||||
total.set(t);
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@ use dioxus::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::posts::{create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse};
|
||||
use crate::api::posts::{
|
||||
create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
|
||||
};
|
||||
use crate::components::write_skeleton::WriteSkeleton;
|
||||
use crate::models::post::Post;
|
||||
use crate::router::Route;
|
||||
@ -63,21 +65,21 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
if is_edit {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(id) = post_id {
|
||||
spawn(async move {
|
||||
match get_post_by_id(id).await {
|
||||
Ok(SinglePostResponse { post: Some(post) }) => {
|
||||
edit_post.set(Some(post));
|
||||
spawn(async move {
|
||||
match get_post_by_id(id).await {
|
||||
Ok(SinglePostResponse { post: Some(post) }) => {
|
||||
edit_post.set(Some(post));
|
||||
}
|
||||
Ok(SinglePostResponse { post: None }) => {
|
||||
load_error.set(Some("文章不存在".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
load_error.set(Some(format!("加载失败: {}", e)));
|
||||
}
|
||||
}
|
||||
Ok(SinglePostResponse { post: None }) => {
|
||||
load_error.set(Some("文章不存在".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
load_error.set(Some(format!("加载失败: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@ -2,8 +2,8 @@ use dioxus::prelude::*;
|
||||
use dioxus::router::components::Link;
|
||||
|
||||
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::components::skeletons::archive_skeleton::ArchiveSkeleton;
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::models::post::Post;
|
||||
use crate::router::Route;
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ use dioxus::router::components::Link;
|
||||
use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
|
||||
use crate::components::post_card::PostCard;
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::components::skeletons::tags_skeleton::{TagsSkeleton, TagDetailSkeleton};
|
||||
use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton};
|
||||
use crate::router::Route;
|
||||
|
||||
#[component]
|
||||
|
||||
@ -5,7 +5,9 @@ use crate::components::admin_layout::AdminLayout;
|
||||
use crate::components::frontend_layout::FrontendLayout;
|
||||
use crate::context::UserContext;
|
||||
use crate::pages::about::About;
|
||||
use crate::pages::admin::{Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit};
|
||||
use crate::pages::admin::{
|
||||
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit,
|
||||
};
|
||||
use crate::pages::archives::Archives;
|
||||
use crate::pages::home::{Home, HomePage};
|
||||
use crate::pages::login::Login;
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod time;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod text;
|
||||
pub mod time;
|
||||
|
||||
@ -1,28 +1,21 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static CODE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"```[\s\S]*?```").unwrap()
|
||||
});
|
||||
static CODE_BLOCK_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"```[\s\S]*?```").unwrap());
|
||||
|
||||
static INLINE_CODE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"`[^`]*`").unwrap()
|
||||
});
|
||||
static INLINE_CODE_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"`[^`]*`").unwrap());
|
||||
|
||||
static LINK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").unwrap()
|
||||
});
|
||||
static LINK_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").unwrap());
|
||||
|
||||
static HEADING_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"^#{1,6}\s*").unwrap()
|
||||
});
|
||||
static HEADING_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"^#{1,6}\s*").unwrap());
|
||||
|
||||
static IMAGE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"!\[([^\]]*)\]\([^)]*\)").unwrap()
|
||||
});
|
||||
static IMAGE_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"!\[([^\]]*)\]\([^)]*\)").unwrap());
|
||||
|
||||
static WHITESPACE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"\s+").unwrap()
|
||||
});
|
||||
static WHITESPACE_RE: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new(r"\s+").unwrap());
|
||||
|
||||
pub fn strip_markdown(md: &str) -> String {
|
||||
let mut plain = CODE_BLOCK_RE.replace_all(md, "").to_string();
|
||||
@ -88,7 +81,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn strip_markdown_keeps_link_text() {
|
||||
assert_eq!(strip_markdown("[click me](https://example.com)"), "click me");
|
||||
assert_eq!(
|
||||
strip_markdown("[click me](https://example.com)"),
|
||||
"click me"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -98,7 +94,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn strip_markdown_removes_bold_and_italic() {
|
||||
assert_eq!(strip_markdown("**bold** *italic* __bold__ _italic_"), "bold italic bold italic");
|
||||
assert_eq!(
|
||||
strip_markdown("**bold** *italic* __bold__ _italic_"),
|
||||
"bold italic bold italic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -178,7 +178,10 @@ mod tests {
|
||||
// Note: LazyLock initializes once, so we can't easily test
|
||||
// different env values in the same process.
|
||||
// Test that the default config is reasonable
|
||||
let config = WebpConfig { quality: 85.0, method: 2 };
|
||||
let config = WebpConfig {
|
||||
quality: 85.0,
|
||||
method: 2,
|
||||
};
|
||||
assert!(config.quality >= 0.0 && config.quality <= 100.0);
|
||||
assert!(config.method <= 6);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user