在 Leptos + Axum 中基于 Accept 头返回 Markdown

发表于 2026年09月13日 22:29约 6 分钟读完

探讨在 Leptos 框架中自定义响应体的实现思路。由于 Leptos 本身仅开放响应头修改,无法直接接管整个 Response Body,本文通过注入 Axum 中间件,在路由层抢先识别 Accept 头,从而实现按需下发纯 Markdown 内容。

AI 辅助润色:本文的核心思想与初稿均由作者独立完成,后期借助人工智能进行了语言润色与排版优化。

本页目录

昨天在 Cloudflare 控制台和阿里云 ESA 边缘加速都看到了「将网页转换为 Markdown 文本」的功能,但二者均需升级到付费套餐才能开启。这里引用 Cloudflare 博客的几段话,说明 Markdown 的重要性:

Cloudflare控制台:
Cloudflare控制台: "Agents 的 Markdown"

阿里云ESA:
阿里云ESA: "Markdwon 转换"

为何 Markdown 很重要

将原始 HTML 提供给 AI,就像是按字数付费阅读包装,而不是里面的字母。在 Markdown 页面中添加一个简单的 ## About Us 大约消耗 3 个令牌;而它对应的 HTML 代码 <h2 class="section-title" id="about">About Us</h2> 会占用 12-15 个令牌,这还不包括填充每个真实网页但毫无语义价值的 <div> 包装器、导航栏和脚本标签。

您正在阅读的这篇博客文章在 HTML 中占用 16,180 个令牌,而转换为 Markdown 后仅使用 3,150 个令牌。这相当于令牌使用量减少了 80% 。

Markdown 已迅速成为智能体和整个 AI 系统的通用语言。格式清晰的结构使其非常适合 AI 处理,最终带来更好的结果,同时最大限度地减少令牌浪费。

问题在于,Web 是由 HTML 而不是 Markdown 构成,而且页面大小多年来一直在稳步增长,导致页面难以解析。智能体的目标是过滤掉所有非必要元素,并扫描相关内容。

如今,将 HTML 转换为 Markdown 是任何 AI 管道的常见步骤。不过,这个流程不尽如人意:它会浪费计算资源,增加成本和处理复杂度,最重要的是,这可能并不是内容创作者最初预期的使用方式。

——隆重推出 Markdown for Agents

一、本站内容本来就是 Markdown

由于本站内容在编写阶段就是 Markdown,所以并不需要做「HTML → Markdown」的转换。站点适配只需要在收到请求后,把微调过的 Markdown 文本(加上精简后的元数据等)直接返回即可。

于是问题变成了:如何在 Leptos 中接管文章路由,并返回符合标准的自定义响应。

这里有一个架构细节:我的文章组件是放在 HomeOutlet 里的。这意味着如果直接在 Post 组件里把内容改成 Markdown 文本,返回时会连带着站点的 Header 和 Footer 一起输出——这不是我们想要的结果。

所以最初的思路是:在 Leptos 服务端,当访问路径是文章并在Acceot请求头中存在text/markdown时,就跳过组件构建渲染,直接覆盖抢答整个响应。

二、在 Leptos 中做的的尝试

翻了一遍官方文档,也读了一部分 Leptos 与 Axum 的耦合库,发现 Leptos 只公开了 ResponseParts 和 ResponseOptions 两个结构体:

pub struct ResponseOptions(pub Arc<RwLock<ResponseParts>>);

// 这个结构体允许你在组件或 Server Function 中设置响应头、覆盖响应状态码。
// 通常放在 ResponseOptions 里,常用于设置 Cookie 或自定义响应。
pub struct ResponseParts {
    pub status: Option<StatusCode>,
    pub headers: HeaderMap,
}

也就是说,Leptos 只允许修改状态码和响应头,无法接管、覆盖整个响应体即Response<Body>(当然,这肯定不是 Leptos 的缺陷,而是框架的普遍设计。)。即使通过 #[cfg(feature = "ssr")] 强制把返回内容改成纯 Markdown,到了客户端渲染阶段也一定会因为 hydration 不匹配而 panic。

三、用 Axum 中间件按需接管 Markdown 响应

既然在 Leptos 框架层面无法实现,那能不能在更上层的 Axum 里做?

判断标准其实很清晰:请求头 Accept 中包含 text/markdown,就说明这个请求希望得到 Markdown 纯文本。再确认这个 URI 是合法的文章路径,就可以在 Axum 中间件层抢先于 Leptos 把数据库里的 Markdown 文本作为响应返回。

Axum 的 middleware::from_fn 正好能做这件事:它在 Leptos 路由之前执行,并且可以自由选择是「抢答」还是「放行给下一层」。

axum::middleware::from_fn
pub fn from_fn<F, T>(f: F) -> FromFnLayer<F, (), T>

F = fn guard(Request<Body>, …) -> …, T = (Request<Body>,)

Create a middleware from an async function.

from_fn requires the function given to

  1. Be an async fn.
  2. Take zero or more FromRequestParts extractors.
  3. Take exactly one FromRequest extractor as the second to last argument.
  4. Take Next as the last argument.
  5. Return something that implements IntoResponse.

Note that this function doesn’t support extracting State. For that, use from_fn_with_state.

Example

use axum::{
    Router,
    http,
    routing::get,
    response::Response,
    middleware::{self, Next},
    extract::Request,
};

async fn my_middleware(
    request: Request,
    next: Next,
) -> Response {
    // do something with `request`...

    let response = next.run(request).await;

    // do something with `response`...

    response
}

let app = Router::new()
    .route("/", get(|| async { /* ... */ }))
    .layer(middleware::from_fn(my_middleware));

Running extractors

use axum::{
    Router,
    extract::Request,
    http::{StatusCode, HeaderMap},
    middleware::{self, Next},
    response::Response,
    routing::get,
};

async fn auth(
    // run the `HeaderMap` extractor
    headers: HeaderMap,
    // you can also add more extractors here but the last
    // extractor must implement `FromRequest` which
    // `Request` does
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    match get_token(&headers) {
        Some(token) if token_is_valid(token) => {
            let response = next.run(request).await;
            Ok(response)
        }
        _ => {
            Err(StatusCode::UNAUTHORIZED)
        }
    }
}

fn get_token(headers: &HeaderMap) -> Option<&str> {
    // ...
}

fn token_is_valid(token: &str) -> bool {
    // ...
}

let app = Router::new()
    .route("/", get(|| async { /* ... */ }))
    .route_layer(middleware::from_fn(auth));

四、实现:中间件与路由集成

4.1 中间件代码

pub async fn accept_markdown_example(mut req: Request<Body>, next: Next) -> Response {
    if req
        .headers()
        .get(HeaderName::from_static("accept"))
        .and_then(|h| h.to_str().ok())
        .is_some_and(|v| v.contains("text/markdown"))
    {
	    todo!("在这里先校验 URI 是否为合法文章路径,再从数据库读取对应的 Markdown 文本;如果为非法请求则交给Leptos处理。");
        let markdown = "### I am markdown content.";
        let length = markdown.len().to_string();
        let mut rep = Response::new(markdown.into());
        let rep_header = rep.headers_mut();
        rep_header.append(
            ContentType::name(),
            HeaderValue::from_static("text/markdown; charset=utf-8"),
        );
		// Cloudflare 推进的 content-signal字段。
        rep_header.append(
            HeaderName::from_static("content-signal"),
            HeaderValue::from_static("ai-train=yes, search=yes, ai-input=yes"),
        );
        if let Ok(length) = HeaderValue::from_str(&length) {
            rep_header.append(HeaderName::from_static("content-length"), length);
        }

        return rep;
    } else {
        next.run(req).await
    }
}

4.2 main 函数集成

#[tokio::main]
async fn main() {
	...
    let app = Router::new()
    	...
        .leptos_routes(&leptos_options, routes, {
            let leptos_options = leptos_options.clone();
            move || shell(leptos_options.clone())
        })
        .fallback_service(leptos_axum::site_pkg_dir_service(&leptos_options).fallback(
            leptos_axum::ErrorHandler::new(shell, leptos_options.clone()),
        ))
        .with_state(leptos_options)
        .layer(from_fn(changjiu::markdown_response::accept_markdown));
    let app: tower_http::normalize_path::NormalizePath<Router> =
        tower_http::normalize_path::NormalizePathLayer::trim_trailing_slash().layer(app);
  
    info!("listening on http://{}", &addr);
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    axum::serve(
        listener,
        ServiceExt::<Request>::into_make_service_with_connect_info::<SocketAddr>(app),
    )
    .await
    .unwrap();
}

‍

五、验证:用 curl 请求 Markdown 版本

在终端里对本篇文章发一个请求,就能看到它同时具备两种表示:

# 只看响应头(-I 即 HEAD 请求)
curl -sI "https://www.changjiu365.cn/tutorial/rust/leptos-axum-accept-header-markdown" \
  -H "Accept: text/markdown"

# 连同响应体一起取回
curl -s "https://www.changjiu365.cn/tutorial/rust/leptos-axum-accept-header-markdown" \
  -H "Accept: text/markdown"

响应头:

HTTP/2 200

content-type: text/markdown; charset=utf-8
content-length: xxx
content-signal: ai-train=yes, search=yes, ai-input=yes

响应体则是文章本身的 Markdown,外加一段精简的 Front Matter:

+++
title = "在 Leptos + Axum 中基于 Accept 头返回 Markdown"
...
+++
# 在 Leptos + Axum 中基于 Accept 头返回 Markdown

昨天在 Cloudflare 控制台和阿里云 ESA 边缘加速都看到了「将网页转换为 Markdown 文本」的功能,但二者均需升级到付费套餐才能开启。这里引用 Cloudflare 博客的几段话,说明 Markdown 的重要性:
... ...

作为对照,把 -H "Accept: text/markdown" 去掉,同一个 URL 返回的就是完整的 HTML 源码。如果 Accept 里没有 text/markdown、或者 URI 不是合法文章路径,中间件会直接 next.run(req).await 放行给 Leptos,行为与改动前完全一致。

发表评论

理性发言,友善交流,评论将在审核通过后展示。

0
邮箱仅用于回复通知,不会公开显示。

全部评论(共 0 条)

暂无评论,欢迎发表第一条评论。