feat(markdown): support rendering and sanitizing runnable pre code-blocks

- markdown.rs: CodeBlock 处理识别 `runnable` 标记 + 受支持语言,在 <pre> 上
  挂 data-runnable/data-lang/data-overrides;overrides JSON 经 escape_html 转义
  防属性注入;非可运行围栏的 language-xxx 改取纯语言 token(首个空白前)
- sanitizer.rs: clean_html 放行 <pre> 的 data-runnable/data-lang/data-overrides
- 新增 4 个 markdown 测试 + 2 个 sanitizer 测试覆盖转义/白名单/字段顺序
This commit is contained in:
xfy 2026-07-03 18:19:26 +08:00
parent 9fe128d44d
commit a77a2e3502
2 changed files with 145 additions and 4 deletions

View File

@ -90,6 +90,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut in_heading = false;
let mut in_codeblock = false;
let mut code_lang: Option<String> = None;
/// 可运行代码块的 (lang, html-escaped overrides JSON);为 None 表示普通代码块。
let mut code_runnable: Option<(String, String)> = None;
let mut code_buffer = String::new();
let mut non_heading_events: Vec<Event> = Vec::new();
@ -151,6 +153,25 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
}
_ => None,
};
// 解析围栏 info识别 `runnable` 标记与可选 ResourceLimits JSON 覆盖。
// 仅在「标记为 runnable 且语言受支持」时挂 data-*,供阅读器扫描挂载运行器。
code_runnable = code_lang
.as_deref()
.map(|info| {
let (lang, runnable, overrides) =
crate::api::code_runner::languages::parse_fence_info(info);
if runnable && crate::api::code_runner::languages::is_supported_lang(&lang) {
// overrides 序列化为 JSON 后 HTML 转义,避免属性注入。
let ov_json = overrides
.map(|o| serde_json::to_string(&o).unwrap_or_default())
.unwrap_or_default();
let escaped = crate::utils::html::escape_html(&ov_json);
Some((lang, escaped))
} else {
None
}
})
.flatten();
code_buffer.clear();
}
Event::Text(text) if in_codeblock => {
@ -160,11 +181,24 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
// 使用 syntect 对代码块进行服务端语法高亮。
let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
html.push_str("<pre><code");
if let Some(lang) = &code_lang {
html.push_str(&format!(" class=\"language-{lang}\""));
// 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides
// 阅读器post_content.rs客户端扫描这些标记原地挂载 CodeRunner 组件。
if let Some((lang, overrides_escaped)) = code_runnable.take() {
html.push_str(&format!(
r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}"><code class="language-{lang}">"#
));
} else {
html.push_str("<pre><code");
if let Some(lang) = &code_lang {
// 围栏语言可能含 info 修饰(如 `python runnable {...}`
// 高亮的 language-xxx 取纯语言 token首个空白前
let clean_lang = lang.split_whitespace().next().unwrap_or("");
if !clean_lang.is_empty() {
html.push_str(&format!(" class=\"language-{clean_lang}\""));
}
}
html.push('>');
}
html.push('>');
html.push_str(&highlighted);
html.push_str("</code></pre>");
in_codeblock = false;
@ -631,6 +665,76 @@ mod tests {
assert!(result.html.contains("plain text"));
}
#[test]
fn render_markdown_runnable_block_emits_data_attrs() {
// `python runnable` 围栏pre 上挂 data-runnable / data-lang / data-overrides
// 阅读器据此原地挂载 CodeRunner 组件。
let result = render_markdown_enhanced("```python runnable\nprint('hi')\n```");
assert!(
result.html.contains(r#"data-runnable="true""#),
"应输出 data-runnable, got: {}",
result.html
);
assert!(
result.html.contains(r#"data-lang="python""#),
"应输出 data-lang=python, got: {}",
result.html
);
// 无 overrides 时 data-overrides 为空。
assert!(
result.html.contains(r#"data-overrides="""#),
"无 overrides 时 data-overrides 应为空, got: {}",
result.html
);
// 内部仍带高亮 code 与 language-python。
assert!(
result.html.contains(r#"<code class="language-python">"#),
"应保留高亮 code, got: {}",
result.html
);
}
#[test]
fn render_markdown_runnable_block_with_overrides() {
let result = render_markdown_enhanced(
r#"```node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":false,"cpu_cores":1.0,"output_bytes":1024}
console.log(1)
```"#,
);
assert!(result.html.contains(r#"data-lang="node""#));
// overrides JSON 应被 HTML 转义后放入属性(双引号变 &quot;),不得出现裸引号越界。
// 字段顺序按 serde 派生默认字母序cpu_cores 在前。
assert!(
result.html.contains("data-overrides=\"{&quot;cpu_cores&quot;:1.0"),
"overrides 应 HTML 转义, got: {}",
result.html
);
// 安全:不得出现可越出属性边界的裸双引号 JSON。
assert!(
!result.html.contains(r#"data-overrides="{"timeout""#),
"overrides 裸引号越界, got: {}",
result.html
);
}
#[test]
fn render_markdown_runnable_marker_on_unsupported_lang_ignored() {
// 语言不在白名单runnable 标记被忽略,输出普通代码块。
let result = render_markdown_enhanced("```rust runnable\nfn main(){}\n```");
assert!(
!result.html.contains("data-runnable"),
"不支持的语言不应挂 data-runnable, got: {}",
result.html
);
}
#[test]
fn render_markdown_plain_fence_without_runnable_no_data_attrs() {
let result = render_markdown_enhanced("```python\nprint(1)\n```");
assert!(!result.html.contains("data-runnable"));
assert!(result.html.contains(r#"<pre><code class="language-python">"#));
}
#[test]
fn render_markdown_code_block_preserves_html_content() {
let result = render_markdown_enhanced("```html\n<script>alert(1)</script>\n```");

View File

@ -372,6 +372,12 @@ pub fn clean_html(input: &str) -> String {
("img", vec!["data-src", "class", "style"]),
// input 仅放行 checkbox 必备属性;type 的具体取值由 element_handler 强校验为 checkbox。
("input", vec!["type", "checked", "disabled"]),
// pre 上的可运行代码块标记data-runnable / data-lang / data-overrides。
// data-overrides 是 markdown 渲染时 HTML 转义后的 JSON见 markdown.rs不含未转义引号。
(
"pre",
vec!["data-runnable", "data-lang", "data-overrides"],
),
("span", vec!["class", "style"]),
("h1", vec!["id", "class"]),
("h2", vec!["id", "class"]),
@ -671,4 +677,35 @@ mod tests {
"评论侧 input 应被剥离, got: {result}"
);
}
#[test]
fn clean_html_preserves_runnable_pre_data_attrs() {
// 可运行代码块标记应完整保留,供阅读器扫描挂载 CodeRunner。
let input = r#"<pre data-runnable="true" data-lang="python" data-overrides="{&quot;timeout_secs&quot;:10}"><code class="language-python">print(1)</code></pre>"#;
let result = clean_html(input);
assert!(
result.contains(r#"data-runnable="true""#),
"data-runnable 应保留, got: {result}"
);
assert!(
result.contains(r#"data-lang="python""#),
"data-lang 应保留, got: {result}"
);
assert!(
result.contains("data-overrides="),
"data-overrides 应保留, got: {result}"
);
}
#[test]
fn clean_html_strips_unknown_data_attrs_on_pre() {
// 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick应被剥离。
let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
let result = clean_html(input);
assert!(result.contains("data-runnable"), "白名单 data-runnable 应保留");
assert!(
!result.contains("data-evil"),
"未知 data-* 应被剥离, got: {result}"
);
}
}