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

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

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

昨天在 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条)