Compare commits

...

2 Commits

Author SHA1 Message Date
xfy
2c1190d8fb 移除 SSR HTML minify 中间件及相关工具
Some checks failed
CI / check (push) Failing after 5m21s
CI / build (push) Has been skipped
2026-06-17 10:43:09 +08:00
xfy
476fad27e5 fix(minify): preserve Dioxus hydration markers and remove unsafe per-URL cache
- Keep Dioxus SSR comments (<!--#-->, <!--node-id<N>-->, <!--placeholder-->)

  during HTML minification to avoid hydration failures.

- Drop the per-URL minify cache in the Axum middleware: the same URL can

  render differently for logged-in vs anonymous users, so URL-level caching

  risks leaking admin UI into public responses.

- Let the middleware handle HTML minification once instead of minifying in

  markdown rendering as well.

- Consolidate CSS comment stripping into the existing minify_css path and

  add a benchmark for html_minify.
2026-06-17 10:43:09 +08:00
7 changed files with 20 additions and 244 deletions

View File

@ -190,8 +190,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
}
RenderedContent {
html: crate::utils::html_minify::minify_html(&clean_html(&html)),
toc_html: crate::utils::html_minify::minify_html(&toc_html),
html: clean_html(&html),
toc_html,
}
}

View File

@ -34,13 +34,9 @@ fn main() {
let mocha_css = css_for_theme_with_class_style(&mocha, ClassStyle::Spaced)
.expect("Failed to generate Mocha CSS");
// 移除 syntect 生成的 /* ... */ 注释,减小最终 CSS 体积
let latte_clean = strip_comments(&latte_css);
let mocha_clean = strip_comments(&mocha_css);
// 重写选择器:浅色主题无前缀,深色主题加 `.dark ` 前缀
let latte_rewritten = rewrite_rules(&latte_clean, ".md-content pre code", "");
let mocha_rewritten = rewrite_rules(&mocha_clean, ".md-content pre code", ".dark ");
let latte_rewritten = rewrite_rules(&latte_css, ".md-content pre code", "");
let mocha_rewritten = rewrite_rules(&mocha_css, ".md-content pre code", ".dark ");
let mut output = String::new();
output.push_str("/* Auto-generated by generate_highlight_css — DO NOT EDIT */\n");
@ -49,7 +45,7 @@ fn main() {
output.push_str("/* Catppuccin Mocha (dark) */\n");
output.push_str(&mocha_rewritten);
// 压缩最终 CSS去掉注释、空白和换行
// 压缩最终 CSS注释、空白、换行统一在这一步处理minify_css 会跳过 /* */
let output = minify_css(&output);
// 确保 public/ 目录存在并写入生成的 CSS
@ -59,42 +55,6 @@ fn main() {
println!("Generated public/highlight.css");
}
/// 去除 CSS 文本中的 C 风格 /* ... */ 注释。
fn strip_comments(css: &str) -> String {
let mut result = String::with_capacity(css.len());
let chars: Vec<char> = css.chars().collect();
let mut i = 0;
while i < chars.len() {
if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '*' {
// 遇到 /* 后跳过直到找到配对的 */
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2;
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
/// 将 syntect 生成的 CSS 选择器包装到指定的 base 选择器下,并可附加前缀。
///
/// syntect 原始输出形如:
/// .code { ... }
/// .kw { color: #xxx; }
/// .kw, .kt { color: #yyy; }
///
/// 转换后变为:
/// .md-content pre code { ... }
/// .dark .md-content pre code .kw { ... }
///
/// 参数:
/// - `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());
@ -140,6 +100,21 @@ fn minify_css(css: &str) -> String {
out.trim().to_string()
}
/// 将 syntect 生成的 CSS 选择器包装到指定的 base 选择器下,并可附加前缀。
///
/// syntect 原始输出形如:
/// .code { ... }
/// .kw { color: #xxx; }
/// .kw, .kt { color: #yyy; }
///
/// 转换后变为:
/// .md-content pre code { ... }
/// .dark .md-content pre code .kw { ... }
///
/// 参数:
/// - `css`: syntect 生成的原始 CSS
/// - `base`: 作用域基础选择器,这里固定为 `.md-content pre code`
/// - `prefix`: 主题前缀,浅色主题传空字符串,深色主题传 `.dark `。
fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String {
let mut out = String::new();
let mut pos = 0;

View File

@ -21,7 +21,6 @@ mod db;
mod highlight;
mod hooks;
#[cfg(feature = "server")]
mod middleware;
mod models;
mod pages;
mod router;
@ -104,11 +103,6 @@ fn main() {
// 合并三条路由:自定义 API、静态资源、Dioxus 主应用
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)
});
}

View File

@ -1,113 +0,0 @@
//! 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 path = uri.path().to_string();
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 status = response.status();
let is_admin_or_auth = path.starts_with("/admin") || path == "/login" || path == "/register";
let should_cache = status.is_success() && !is_admin_or_auth;
// 缓存 key 必须包含完整 query string避免不同参数共享同一份响应。
let cache_key = format!(
"{}{}",
path,
uri.query().map(|q| format!("?{}", q)).unwrap_or_default()
);
if should_cache {
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);
// 仅对成功且非后台页面写入缓存。
if should_cache {
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
}

View File

@ -1,2 +0,0 @@
#[cfg(feature = "server")]
pub mod minify_html;

View File

@ -1,75 +0,0 @@
//! 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
}

View File

@ -7,6 +7,3 @@
pub mod text;
/// 跨平台的异步睡眠等时间工具。
pub mod time;
/// HTML 空白压缩工具。
#[cfg(feature = "server")]
pub mod html_minify;