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.
This commit is contained in:
parent
449a545886
commit
476fad27e5
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
//! SSR HTML 空白压缩中间件。
|
||||
//!
|
||||
//! 对 Dioxus fullstack 返回的 `text/html` 响应做轻量 minify。
|
||||
//! 为了避免 SSR 增量渲染缓存命中后仍然重复 minify,中间件内部维护了一个按 URL
|
||||
//! 缓存的内存缓存(容量有限、TTL 较短),minify 后的结果会直接复用。
|
||||
//!
|
||||
//! 注意:这里**不**维护 per-URL 缓存。同一个 URL 在 admin 登录态与匿名态下
|
||||
//! 渲染出的 HTML 不同(例如是否带管理按钮),按 URL 缓存会导致跨用户内容
|
||||
//! 泄露。Dioxus 的增量渲染器(ISRG)本身已经按 URL 缓存了 SSR HTML,
|
||||
//! 这里的 minify 只是对其输出做一次无损空白折叠,无需再叠加一层缓存。
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use axum::{
|
||||
@ -13,22 +16,9 @@ use axum::{
|
||||
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
|
||||
@ -42,24 +32,6 @@ pub async fn layer(request: Request, next: Next) -> Response {
|
||||
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(),
|
||||
@ -74,11 +46,6 @@ pub async fn layer(request: Request, next: Next) -> 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")
|
||||
@ -94,20 +61,3 @@ pub async fn layer(request: Request, next: Next) -> Response {
|
||||
|
||||
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,13 +2,28 @@
|
||||
//!
|
||||
//! 仅服务端使用。对 HTML 片段做轻量 minify:
|
||||
//! - 合并标签之间连续空白为一个空格;
|
||||
//! - 移除 HTML 注释;
|
||||
//! - 移除普通 HTML 注释,但**保留 Dioxus hydration marker 注释**;
|
||||
//! - 保留 `<pre>`、`<code>`、`<textarea>`、`<script>`、`<style>` 内部原样空白。
|
||||
//!
|
||||
//! Dioxus 0.7 SSR 会在输出中插入若干 load-bearing 的注释 marker,客户端
|
||||
//! hydration 依赖它们定位动态节点。删除这些注释会导致前端 hydration
|
||||
//! 失败(节点对不齐、交互失效)。需要保留的格式:
|
||||
//! `<!--#-->` 动态文本节点结束 marker
|
||||
//! `<!--node-id<N>-->` 动态文本节点起始 marker
|
||||
//! `<!--placeholder-->` / `<!--placeholder<N>-->` 占位节点 marker
|
||||
//! (参考 others/dioxus/packages/ssr/src/renderer.rs)
|
||||
|
||||
use lol_html::{doc_comments, doc_text, element, rewrite_str, RewriteStrSettings};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// 判断一段注释文本是否为 Dioxus hydration marker,必须原样保留。
|
||||
fn is_hydration_marker(comment_text: &str) -> bool {
|
||||
comment_text == "#"
|
||||
|| comment_text.starts_with("node-id")
|
||||
|| comment_text.starts_with("placeholder")
|
||||
}
|
||||
|
||||
/// 压缩 HTML 中的无用空白。
|
||||
pub fn minify_html(input: &str) -> String {
|
||||
let protected_depth: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||
@ -46,7 +61,10 @@ pub fn minify_html(input: &str) -> String {
|
||||
}
|
||||
}),
|
||||
doc_comments!(|c| {
|
||||
c.remove();
|
||||
// 只删除非 hydration marker 的普通注释。
|
||||
if !is_hydration_marker(c.text().as_str()) {
|
||||
c.remove();
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
],
|
||||
|
||||
39
src/utils/html_minify_bench.rs
Normal file
39
src/utils/html_minify_bench.rs
Normal file
@ -0,0 +1,39 @@
|
||||
//! 临时 bench:测量 minify_html 对真实大小 HTML 的耗时。
|
||||
//! 用完即删。
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod bench {
|
||||
use std::time::Instant;
|
||||
|
||||
fn synthetic_html(size_hint: usize) -> String {
|
||||
// 构造一个带空白、注释、嵌套标签的模拟 HTML,体积接近真实 SSR 输出。
|
||||
let unit = "<div class=\"x\">\n <p>hello world</p>\n <!-- comment -->\n <a href=\"/p\">link</a>\n</div>\n";
|
||||
let unit_len = unit.len();
|
||||
let repeats = size_hint / unit_len + 1;
|
||||
unit.repeat(repeats)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_minify_throughput() {
|
||||
for &kb in &[50usize, 200, 568] {
|
||||
let html = synthetic_html(kb * 1024);
|
||||
// 预热
|
||||
let _ = crate::utils::html_minify::minify_html(&html);
|
||||
|
||||
let n = 20;
|
||||
let start = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = crate::utils::html_minify::minify_html(&html);
|
||||
}
|
||||
let elapsed = start.elapsed() / n;
|
||||
let input_kb = html.len() as f64 / 1024.0;
|
||||
let mbps = (html.len() as f64 / elapsed.as_secs_f64()) / (1024.0 * 1024.0);
|
||||
println!(
|
||||
"input={:.0}KB avg minify={:?} (~{:.0} MB/s)",
|
||||
input_kb,
|
||||
elapsed,
|
||||
mbps
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,3 +10,6 @@ pub mod time;
|
||||
/// HTML 空白压缩工具。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod html_minify;
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod html_minify_bench;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user