perf: add HTML/CSS minification for SSR responses and highlight CSS
- Add lol_html-based HTML whitespace minifier (utils/html_minify.rs) that collapses whitespace and strips comments while preserving <pre>, <code>, <textarea>, <script>, <style> content - Add Axum middleware (middleware/minify_html.rs) that minifies text/html responses with per-URL moka cache (256 entries, 300s TTL) - Wire middleware into the server router in main.rs - Add CSS minifier to generate_highlight_css build step - Apply minify_html to rendered markdown output and TOC - Add http-body-util dependency for body collection in middleware
This commit is contained in:
parent
ae84c37f21
commit
bd659c5b4f
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5312,6 +5312,7 @@ dependencies = [
|
|||||||
"governor",
|
"governor",
|
||||||
"hex",
|
"hex",
|
||||||
"http",
|
"http",
|
||||||
|
"http-body-util",
|
||||||
"image",
|
"image",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"lol_html",
|
"lol_html",
|
||||||
|
|||||||
@ -21,6 +21,7 @@ tower-http = { version = "0.6", optional = true }
|
|||||||
rand = { version = "0.8", features = ["getrandom"], optional = true }
|
rand = { version = "0.8", features = ["getrandom"], optional = true }
|
||||||
http = { version = "1", optional = true }
|
http = { version = "1", optional = true }
|
||||||
axum = { version = "0.8", optional = true, features = ["multipart"] }
|
axum = { version = "0.8", optional = true, features = ["multipart"] }
|
||||||
|
http-body-util = { version = "0.1", optional = true }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
sha2 = { version = "0.10", optional = true }
|
sha2 = { version = "0.10", optional = true }
|
||||||
hex = { version = "0.4", optional = true }
|
hex = { version = "0.4", optional = true }
|
||||||
@ -72,6 +73,7 @@ server = [
|
|||||||
"dep:tracing",
|
"dep:tracing",
|
||||||
"dep:tracing-subscriber",
|
"dep:tracing-subscriber",
|
||||||
"dep:tower-http",
|
"dep:tower-http",
|
||||||
|
"dep:http-body-util",
|
||||||
"dep:lol_html",
|
"dep:lol_html",
|
||||||
"dep:syntect",
|
"dep:syntect",
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
|
|||||||
@ -190,8 +190,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
RenderedContent {
|
RenderedContent {
|
||||||
html: clean_html(&html),
|
html: crate::utils::html_minify::minify_html(&clean_html(&html)),
|
||||||
toc_html,
|
toc_html: crate::utils::html_minify::minify_html(&toc_html),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,12 +43,15 @@ fn main() {
|
|||||||
let mocha_rewritten = rewrite_rules(&mocha_clean, ".md-content pre code", ".dark ");
|
let mocha_rewritten = rewrite_rules(&mocha_clean, ".md-content pre code", ".dark ");
|
||||||
|
|
||||||
let mut output = String::new();
|
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("/* Catppuccin Latte (light) */\n");
|
||||||
output.push_str(&latte_rewritten);
|
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);
|
output.push_str(&mocha_rewritten);
|
||||||
|
|
||||||
|
// 压缩最终 CSS:去掉注释、空白和换行
|
||||||
|
let output = minify_css(&output);
|
||||||
|
|
||||||
// 确保 public/ 目录存在并写入生成的 CSS
|
// 确保 public/ 目录存在并写入生成的 CSS
|
||||||
std::fs::create_dir_all("public").expect("Failed to create public/");
|
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");
|
||||||
@ -91,6 +94,51 @@ fn strip_comments(css: &str) -> String {
|
|||||||
/// - `css`: syntect 生成的原始 CSS;
|
/// - `css`: syntect 生成的原始 CSS;
|
||||||
/// - `base`: 作用域基础选择器,这里固定为 `.md-content pre code`;
|
/// - `base`: 作用域基础选择器,这里固定为 `.md-content pre code`;
|
||||||
/// - `prefix`: 主题前缀,浅色主题传空字符串,深色主题传 `.dark `。
|
/// - `prefix`: 主题前缀,浅色主题传空字符串,深色主题传 `.dark `。
|
||||||
|
/// 压缩 CSS:移除注释、合并空白、删除选择器/属性周围的无用空格。
|
||||||
|
fn minify_css(css: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(css.len());
|
||||||
|
let chars: Vec<char> = css.chars().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut last_significant: Option<char> = 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 {
|
fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
|
|||||||
@ -20,6 +20,8 @@ mod db;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod highlight;
|
mod highlight;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod middleware;
|
||||||
mod models;
|
mod models;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod router;
|
mod router;
|
||||||
@ -101,6 +103,11 @@ fn main() {
|
|||||||
// 合并三条路由:自定义 API、静态资源、Dioxus 主应用
|
// 合并三条路由:自定义 API、静态资源、Dioxus 主应用
|
||||||
let router = api_routes.merge(static_routes).merge(dioxus_app);
|
let router = api_routes.merge(static_routes).merge(dioxus_app);
|
||||||
|
|
||||||
|
// 对 SSR 返回的 HTML 做空白压缩(保留 <pre>/<code> 等标签内格式)
|
||||||
|
let router = router.layer(axum::middleware::from_fn(
|
||||||
|
crate::middleware::minify_html::layer,
|
||||||
|
));
|
||||||
|
|
||||||
Ok(router)
|
Ok(router)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/middleware/minify_html.rs
Normal file
98
src/middleware/minify_html.rs
Normal file
@ -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<Cache<String, String>> =
|
||||||
|
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
|
||||||
|
}
|
||||||
2
src/middleware/mod.rs
Normal file
2
src/middleware/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub mod minify_html;
|
||||||
75
src/utils/html_minify.rs
Normal file
75
src/utils/html_minify.rs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
//! HTML 空白压缩工具函数。
|
||||||
|
//!
|
||||||
|
//! 仅服务端使用。对 HTML 片段做轻量 minify:
|
||||||
|
//! - 合并标签之间连续空白为一个空格;
|
||||||
|
//! - 移除 HTML 注释;
|
||||||
|
//! - 保留 `<pre>`、`<code>`、`<textarea>`、`<script>`、`<style>` 内部原样空白。
|
||||||
|
|
||||||
|
use lol_html::{doc_comments, doc_text, element, rewrite_str, RewriteStrSettings};
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// 压缩 HTML 中的无用空白。
|
||||||
|
pub fn minify_html(input: &str) -> String {
|
||||||
|
let protected_depth: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||||
|
|
||||||
|
rewrite_str(
|
||||||
|
input,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!(
|
||||||
|
"pre, code, textarea, script, style",
|
||||||
|
{
|
||||||
|
let depth = protected_depth.clone();
|
||||||
|
move |el| {
|
||||||
|
depth.set(depth.get() + 1);
|
||||||
|
let depth_end = depth.clone();
|
||||||
|
let _ = el.on_end_tag(lol_html::end_tag!(move |_end| {
|
||||||
|
depth_end.set(depth_end.get().saturating_sub(1));
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)],
|
||||||
|
document_content_handlers: vec![
|
||||||
|
doc_text!({
|
||||||
|
let depth = protected_depth.clone();
|
||||||
|
move |text| {
|
||||||
|
if depth.get() == 0 {
|
||||||
|
let s = text.as_str();
|
||||||
|
let collapsed = collapse_whitespace(s);
|
||||||
|
if collapsed != s {
|
||||||
|
text.set_str(collapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
doc_comments!(|c| {
|
||||||
|
c.remove();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
..RewriteStrSettings::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| input.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将连续空白字符合并为一个空格。
|
||||||
|
fn collapse_whitespace(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut prev_ws = false;
|
||||||
|
for ch in s.chars() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
if !prev_ws {
|
||||||
|
out.push(' ');
|
||||||
|
prev_ws = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
prev_ws = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@ -7,3 +7,6 @@
|
|||||||
pub mod text;
|
pub mod text;
|
||||||
/// 跨平台的异步睡眠等时间工具。
|
/// 跨平台的异步睡眠等时间工具。
|
||||||
pub mod time;
|
pub mod time;
|
||||||
|
/// HTML 空白压缩工具。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub mod html_minify;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user