feat(mcp): 客户端配置代码块添加语法高亮

将配置片段 DTO 从扁平字段重构为 snippets Vec(含 content/content_html),
高亮在服务端经 highlight_code 完成(JSON 配置用 json 语法,CLI 用 bash),
前端以 dangerous_inner_html 渲染并包 .md-content 匹配 highlight.css 作用域。

补 json/bash 语法解析测试,确认两者均产出彩色 span 而非纯文本回退。
This commit is contained in:
xfy 2026-07-28 15:53:20 +08:00
parent 53376a0373
commit 1b772f4534
3 changed files with 93 additions and 43 deletions

View File

@ -262,50 +262,66 @@ fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken {
}
}
/// 4 种客户端配置 + CLI 一行命令的序列化 DTO
/// 单个客户端配置片段:标题 + 原始文本(供复制)+ syntect 高亮 HTML供展示
///
/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`
/// 是 server-only`mcp` 模块整体 `#[cfg(feature = "server")]` 门控),这里复制字段
/// 为可两端共享的 DTO让 WASM 前端能经 server fn 拿到配置字符串。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct McpClientConfigs {
/// Claude Code`.mcp.json` / `~/.claude.json`)。`type: "http"`(非 `streamable-http`)。
pub claude_code_json: String,
/// Cursor`~/.cursor/mcp.json`)。不带 `type` 字段,按 URL 自动识别 streamable-http。
pub cursor_json: String,
/// Cline`cline_mcp_settings.json`)。
pub cline_json: String,
/// Oh-My-Pi`~/.pi/agent/mcp.json` / `.pi/mcp.json`)。字段名 `transport`,非 `type`。
pub omp_json: String,
/// 通用原始 JSON单 server entry
pub generic_json: String,
/// Claude Code CLI 一行命令。
pub claude_cli: String,
/// 高亮 HTML 由 `crate::highlight::server::highlight_code` 生成spaced CSS class 风格,
/// 配合 `public/highlight.css`);前端需将其置于 `.md-content pre code` 作用域下,
/// 否则高亮 CSS 选择器不匹配(见 `src/bin/generate_highlight_css.rs` 的 base 重写)。
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct McpConfigSnippet {
/// 显示标题(含客户端名与目标文件路径)。
pub title: String,
/// 原始配置文本(供「复制」按钮复制,未高亮)。
pub content: String,
/// syntect 高亮后的 HTML`<span>` 序列,无 `<pre>/<code>` 外壳)。
pub content_html: String,
}
/// 根据明文令牌生成 4 种客户端配置 + CLI 一行命令
/// 各客户端 MCP 配置片段集合。
///
/// 配置生成在服务端完成(`crate::mcp::config` 是 server-only 模块),返回给前端展示。
/// `APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`
/// 是 server-only`mcp` 模块整体 `#[cfg(feature = "server")]` 门控);这里把每个片段的
/// 原始文本与高亮 HTML 打包为可两端共享的 DTO让 WASM 前端单次请求即可渲染带高亮的配置块。
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct McpClientConfigs {
/// 有序配置片段(标题、原始文本、高亮 HTML
pub snippets: Vec<McpConfigSnippet>,
}
/// 根据明文令牌生成各客户端配置片段(含 syntect 高亮 HTML
///
/// 配置生成与高亮均在服务端完成(`crate::mcp::config` / `crate::highlight` 均为
/// server-only返回给前端展示。`APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
#[server]
pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::api::auth::get_current_admin_user;
use crate::highlight::server::highlight_code;
let _admin = get_current_admin_user().await?;
let configs = crate::mcp::config::generate_client_configs(
let c = crate::mcp::config::generate_client_configs(
&crate::mcp::config::base_url_from_env(),
&token,
);
Ok(McpClientConfigs {
claude_code_json: configs.claude_code_json,
cursor_json: configs.cursor_json,
cline_json: configs.cline_json,
omp_json: configs.omp_json,
generic_json: configs.generic_json,
claude_cli: configs.claude_cli,
})
// (标题, 内容, 语言)JSON 配置用 json 语法高亮CLI 一行命令用 bash。
let entries: [(&str, String, &str); 6] = [
("Claude Code.mcp.json / ~/.claude.json", c.claude_code_json, "json"),
("Cursor~/.cursor/mcp.json", c.cursor_json, "json"),
("Clinecline_mcp_settings.json", c.cline_json, "json"),
("Oh-My-Pi~/.pi/agent/mcp.json 或 .pi/mcp.json", c.omp_json, "json"),
("通用(单 server entry", c.generic_json, "json"),
("Claude Code CLI", c.claude_cli, "bash"),
];
let snippets = entries
.into_iter()
.map(|(title, content, lang)| McpConfigSnippet {
title: title.to_string(),
content_html: highlight_code(&content, Some(lang)),
content,
})
.collect();
Ok(McpClientConfigs { snippets })
}
#[cfg(not(feature = "server"))]
unreachable!()

View File

@ -508,4 +508,36 @@ const message = ref('Hello Vue!')
let by_ts = highlight_code(code, Some("ts"));
assert_eq!(result, by_ts);
}
#[test]
fn highlight_code_json_produces_spans() {
// MCP 客户端配置页的 JSON 片段经此路径高亮;验证 json 解析为彩色语法而非纯文本。
let code = r#"{"mcpServers": {"yggdrasil": {"type": "http"}}}"#;
let result = highlight_code(code, Some("json"));
assert!(
result.contains("<span"),
"json 应触发语法高亮, got: {}",
result
);
assert!(
!result.contains(r#"<span class="text plain">"#),
"json 不应回退纯文本: {}",
result
);
}
#[test]
fn highlight_code_bash_alias_produces_spans() {
// MCP 客户端配置页的 CLI 一行命令经 bash 别名高亮。
let code = "claude mcp add --transport http yggdrasil https://example.com/mcp";
let result = highlight_code(code, Some("bash"));
assert!(
result.contains("<span"),
"bash 别名应触发语法高亮, got: {}",
result
);
// bash 别名与直接传 sh 的输出须一致(别名表 "bash" -> "sh")。
let by_sh = highlight_code(code, Some("sh"));
assert_eq!(result, by_sh);
}
}

View File

@ -18,7 +18,7 @@ use dioxus::prelude::*;
#[cfg(target_arch = "wasm32")]
use crate::api::mcp_tokens::{
create_mcp_token, get_mcp_client_configs, list_mcp_tokens, reveal_mcp_token, revoke_mcp_token,
McpClientConfigs, TokenLifetime,
McpClientConfigs, McpConfigSnippet, TokenLifetime,
};
#[cfg(target_arch = "wasm32")]
use crate::components::forms::{FormSelect, INPUT_CLASS};
@ -503,12 +503,9 @@ fn ConfigCard() -> Element {
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center", "生成配置中…" }
} else if let Some(c) = configs() {
div { class: "flex flex-col gap-4",
ConfigSnippet { title: "Claude Code.mcp.json / ~/.claude.json".to_string(), content: c.claude_code_json.clone() }
ConfigSnippet { title: "Cursor~/.cursor/mcp.json".to_string(), content: c.cursor_json.clone() }
ConfigSnippet { title: "Clinecline_mcp_settings.json".to_string(), content: c.cline_json.clone() }
ConfigSnippet { title: "Oh-My-Pi~/.pi/agent/mcp.json 或 .pi/mcp.json".to_string(), content: c.omp_json.clone() }
ConfigSnippet { title: "通用(单 server entry".to_string(), content: c.generic_json.clone() }
ConfigSnippet { title: "Claude Code CLI".to_string(), content: c.claude_cli.clone() }
for snippet in c.snippets.iter() {
ConfigSnippet { key: "{snippet.title}", snippet: snippet.clone() }
}
}
} else {
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center",
@ -522,17 +519,18 @@ fn ConfigCard() -> Element {
/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
#[cfg(target_arch = "wasm32")]
#[component]
fn ConfigSnippet(title: String, content: String) -> Element {
fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
let state: McpPageState = use_context();
let mut toast = state.toast;
let title = snippet.title.clone();
rsx! {
div { class: "flex flex-col gap-2",
div { class: "flex items-center justify-between",
span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{title}" }
span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{snippet.title}" }
button {
class: "{BTN_PRIMARY_SM}",
onclick: move |_| {
let cc = content.clone();
let cc = snippet.content.clone();
let tt = title.clone();
spawn(async move {
copy_clipboard_wasm(&cc).await;
@ -542,9 +540,13 @@ fn ConfigSnippet(title: String, content: String) -> Element {
"复制"
}
}
pre {
class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
code { "{content}" }
// .md-content 是 highlight.css 的作用域钩子(.md-content pre code …);
// 仅包裹 pre不触及标题/按钮,避免 prose 排式泄漏。
div { class: "md-content",
pre {
class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
code { dangerous_inner_html: "{snippet.content_html}" }
}
}
}
}