diff --git a/Cargo.lock b/Cargo.lock index 5252a6a..26d0e94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5312,6 +5312,7 @@ dependencies = [ "governor", "hex", "http", + "http-body-util", "image", "js-sys", "lol_html", diff --git a/Cargo.toml b/Cargo.toml index c9e1989..2e337f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ tower-http = { version = "0.6", optional = true } rand = { version = "0.8", features = ["getrandom"], optional = true } http = { version = "1", optional = true } axum = { version = "0.8", optional = true, features = ["multipart"] } +http-body-util = { version = "0.1", optional = true } serde_json = "1.0" sha2 = { version = "0.10", optional = true } hex = { version = "0.4", optional = true } @@ -72,6 +73,7 @@ server = [ "dep:tracing", "dep:tracing-subscriber", "dep:tower-http", + "dep:http-body-util", "dep:lol_html", "dep:syntect", "dep:axum", diff --git a/src/api/markdown.rs b/src/api/markdown.rs index b26320a..3a0324d 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -190,8 +190,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { } RenderedContent { - html: clean_html(&html), - toc_html, + html: crate::utils::html_minify::minify_html(&clean_html(&html)), + toc_html: crate::utils::html_minify::minify_html(&toc_html), } } diff --git a/src/bin/generate_highlight_css.rs b/src/bin/generate_highlight_css.rs index f7580f7..cfb65be 100644 --- a/src/bin/generate_highlight_css.rs +++ b/src/bin/generate_highlight_css.rs @@ -43,12 +43,15 @@ fn main() { let mocha_rewritten = rewrite_rules(&mocha_clean, ".md-content pre code", ".dark "); let mut output = String::new(); - output.push_str("/* Auto-generated by generate_highlight_css — DO NOT EDIT */\n\n"); + output.push_str("/* Auto-generated by generate_highlight_css — DO NOT EDIT */\n"); output.push_str("/* Catppuccin Latte (light) */\n"); output.push_str(&latte_rewritten); - output.push_str("\n/* Catppuccin Mocha (dark) */\n"); + output.push_str("/* Catppuccin Mocha (dark) */\n"); output.push_str(&mocha_rewritten); + // 压缩最终 CSS:去掉注释、空白和换行 + let output = minify_css(&output); + // 确保 public/ 目录存在并写入生成的 CSS 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"); @@ -91,6 +94,51 @@ fn strip_comments(css: &str) -> String { /// - `css`: syntect 生成的原始 CSS; /// - `base`: 作用域基础选择器,这里固定为 `.md-content pre code`; /// - `prefix`: 主题前缀,浅色主题传空字符串,深色主题传 `.dark `。 +/// 压缩 CSS:移除注释、合并空白、删除选择器/属性周围的无用空格。 +fn minify_css(css: &str) -> String { + let mut out = String::with_capacity(css.len()); + let chars: Vec = css.chars().collect(); + let mut i = 0; + let mut last_significant: Option = None; + let mut pending_space = false; + + while i < chars.len() { + // 跳过 /* ... */ 注释 + if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '*' { + i += 2; + while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') { + i += 1; + } + i += 2; + continue; + } + + let ch = chars[i]; + if ch.is_whitespace() { + if last_significant.is_some() { + pending_space = true; + } + i += 1; + continue; + } + + // 在特定分隔符前后不需要空格 + let no_space_before = "{ } : ; ,".contains(ch); + let no_space_after = last_significant.map_or(false, |c| "{ } : ; ,".contains(c)); + + if pending_space && !no_space_after && !no_space_before { + out.push(' '); + } + pending_space = false; + + out.push(ch); + last_significant = Some(ch); + i += 1; + } + + out.trim().to_string() +} + fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String { let mut out = String::new(); let mut pos = 0; diff --git a/src/main.rs b/src/main.rs index 69521e7..ee966d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,8 @@ mod db; #[cfg(feature = "server")] mod highlight; mod hooks; +#[cfg(feature = "server")] +mod middleware; mod models; mod pages; mod router; @@ -101,6 +103,11 @@ fn main() { // 合并三条路由:自定义 API、静态资源、Dioxus 主应用 let router = api_routes.merge(static_routes).merge(dioxus_app); + // 对 SSR 返回的 HTML 做空白压缩(保留
/ 等标签内格式)
+            let router = router.layer(axum::middleware::from_fn(
+                crate::middleware::minify_html::layer,
+            ));
+
             Ok(router)
         });
     }
diff --git a/src/middleware/minify_html.rs b/src/middleware/minify_html.rs
new file mode 100644
index 0000000..de2f639
--- /dev/null
+++ b/src/middleware/minify_html.rs
@@ -0,0 +1,98 @@
+//! SSR HTML 空白压缩中间件。
+//!
+//! 对 Dioxus fullstack 返回的 `text/html` 响应做轻量 minify。
+//! 为了避免 SSR 增量渲染缓存命中后仍然重复 minify,中间件内部维护了一个按 URL
+//! 缓存的内存缓存(容量有限、TTL 较短),minify 后的结果会直接复用。
+
+#[cfg(feature = "server")]
+use axum::{
+    body::Body,
+    extract::Request,
+    http::{header, StatusCode},
+    middleware::Next,
+    response::Response,
+};
+use http_body_util::BodyExt;
+use moka::future::Cache;
+use std::time::Duration;
+
+/// 按 URL 缓存 minify 结果,避免 SSR 缓存命中后重复计算。
+static MINIFY_CACHE: std::sync::LazyLock> =
+    std::sync::LazyLock::new(|| {
+        Cache::builder()
+            .max_capacity(256)
+            .time_to_live(Duration::from_secs(300))
+            .build()
+    });
+
+/// Axum 中间件入口。
+pub async fn layer(request: Request, next: Next) -> Response {
+    let uri = request.uri().clone();
+    let response = next.run(request).await;
+
+    let is_html = response
+        .headers()
+        .get(header::CONTENT_TYPE)
+        .and_then(|v| v.to_str().ok())
+        .map(|ct| ct.starts_with("text/html"))
+        .unwrap_or(false);
+
+    if !is_html {
+        return response;
+    }
+
+    // 命中缓存则直接返回
+    let cache_key = uri.to_string();
+    if let Some(cached) = MINIFY_CACHE.get(&cache_key).await {
+        return build_response(response, cached);
+    }
+
+    let (parts, body) = response.into_parts();
+    let bytes = match body.collect().await {
+        Ok(collected) => collected.to_bytes(),
+        Err(_) => {
+            return Response::builder()
+                .status(StatusCode::INTERNAL_SERVER_ERROR)
+                .body(Body::empty())
+                .expect("failed to build error response");
+        }
+    };
+
+    let original = String::from_utf8_lossy(&bytes);
+    let minified = crate::utils::html_minify::minify_html(&original);
+
+    // 写入缓存(忽略失败)
+    let _ = MINIFY_CACHE.insert(cache_key, minified.clone()).await;
+
+    let mut response = Response::builder()
+        .status(parts.status)
+        .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+        .body(Body::from(minified.clone()))
+        .expect("failed to build minified response");
+
+    // 保留原响应的其他 header,并修正 Content-Length
+    *response.headers_mut() = parts.headers;
+    response.headers_mut().remove(header::TRANSFER_ENCODING);
+    response
+        .headers_mut()
+        .insert(header::CONTENT_LENGTH, minified.len().into());
+
+    response
+}
+
+fn build_response(original: Response, body: String) -> Response {
+    let (parts, _body) = original.into_parts();
+    let mut response = Response::builder()
+        .status(parts.status)
+        .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+        .body(Body::from(body.clone()))
+        .expect("failed to build cached response");
+
+    *response.headers_mut() = parts.headers;
+    response.headers_mut().remove(header::TRANSFER_ENCODING);
+    response
+        .headers_mut()
+        .insert(header::CONTENT_LENGTH, body.len().into());
+
+    response
+}
diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs
new file mode 100644
index 0000000..b276e30
--- /dev/null
+++ b/src/middleware/mod.rs
@@ -0,0 +1,2 @@
+#[cfg(feature = "server")]
+pub mod minify_html;
diff --git a/src/utils/html_minify.rs b/src/utils/html_minify.rs
new file mode 100644
index 0000000..d5a6b5e
--- /dev/null
+++ b/src/utils/html_minify.rs
@@ -0,0 +1,75 @@
+//! HTML 空白压缩工具函数。
+//!
+//! 仅服务端使用。对 HTML 片段做轻量 minify:
+//! - 合并标签之间连续空白为一个空格;
+//! - 移除 HTML 注释;
+//! - 保留 `
`、``、`