feat(ui): scan and mount code-runner component client-side in reader
采用 Dioxus vdom 友好的片段拆分(避免篡改 dangerous_inner_html 产物导致 hydration 冲突): - post_content.rs: split_content_fragments 把 content_html 拆成 Html/Runnable 片段, Runnable 作为 <CodeRunner> 组件穿插渲染;HTML 实体解码还原源码;6 个单测 - markdown.rs: 可运行 pre 增加 data-source(转义后的原始源码), 供阅读器无损提取 - sanitizer.rs: 放行 pre 的 data-source 属性 (任务 7 的 data-source 增量一并在此提交)
This commit is contained in:
parent
6b875e6ba5
commit
0e8b5fd7b7
@ -90,7 +90,9 @@ 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 表示普通代码块。
|
||||
/// 可运行代码块的 (lang, html-escaped overrides JSON)。
|
||||
/// 为 None 表示普通代码块。原始源码在 End 处从 code_buffer 转义后存入 data-source,
|
||||
/// 供阅读器无损提取(避免从高亮 HTML 反解)。
|
||||
let mut code_runnable: Option<(String, String)> = None;
|
||||
let mut code_buffer = String::new();
|
||||
let mut non_heading_events: Vec<Event> = Vec::new();
|
||||
@ -165,8 +167,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
||||
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))
|
||||
let overrides_escaped = crate::utils::html::escape_html(&ov_json);
|
||||
Some((lang, overrides_escaped))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -181,11 +183,13 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
||||
// 使用 syntect 对代码块进行服务端语法高亮。
|
||||
let highlighted =
|
||||
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
|
||||
// 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides,
|
||||
// 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides / data-source,
|
||||
// 阅读器(post_content.rs)客户端扫描这些标记原地挂载 CodeRunner 组件。
|
||||
// data-source 为 HTML 转义后的原始源码,供阅读器无损提取(避免反解高亮 HTML)。
|
||||
if let Some((lang, overrides_escaped)) = code_runnable.take() {
|
||||
let source_escaped = crate::utils::html::escape_html(&code_buffer);
|
||||
html.push_str(&format!(
|
||||
r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}"><code class="language-{lang}">"#
|
||||
r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}" data-source="{source_escaped}"><code class="language-{lang}">"#
|
||||
));
|
||||
} else {
|
||||
html.push_str("<pre><code");
|
||||
@ -667,7 +671,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn render_markdown_runnable_block_emits_data_attrs() {
|
||||
// `python runnable` 围栏:pre 上挂 data-runnable / data-lang / data-overrides,
|
||||
// `python runnable` 围栏:pre 上挂 data-runnable / data-lang / data-overrides / data-source,
|
||||
// 阅读器据此原地挂载 CodeRunner 组件。
|
||||
let result = render_markdown_enhanced("```python runnable\nprint('hi')\n```");
|
||||
assert!(
|
||||
@ -686,6 +690,12 @@ mod tests {
|
||||
"无 overrides 时 data-overrides 应为空, got: {}",
|
||||
result.html
|
||||
);
|
||||
// data-source 携带 HTML 转义后的原始源码(单引号转义为 ')。
|
||||
assert!(
|
||||
result.html.contains(r#"data-source="print('hi')"#),
|
||||
"data-source 应含转义后的源码, got: {}",
|
||||
result.html
|
||||
);
|
||||
// 内部仍带高亮 code 与 language-python。
|
||||
assert!(
|
||||
result.html.contains(r#"<code class="language-python">"#),
|
||||
|
||||
@ -372,11 +372,16 @@ 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 上的可运行代码块标记:data-runnable / data-lang / data-overrides / data-source。
|
||||
// data-overrides / data-source 是 markdown 渲染时 HTML 转义后的内容(见 markdown.rs),不含未转义引号。
|
||||
(
|
||||
"pre",
|
||||
vec!["data-runnable", "data-lang", "data-overrides"],
|
||||
vec![
|
||||
"data-runnable",
|
||||
"data-lang",
|
||||
"data-overrides",
|
||||
"data-source",
|
||||
],
|
||||
),
|
||||
("span", vec!["class", "style"]),
|
||||
("h1", vec!["id", "class"]),
|
||||
|
||||
@ -1,9 +1,97 @@
|
||||
//! 文章内容组件
|
||||
//!
|
||||
//! 渲染由服务端生成的文章 HTML 内容,并在 WASM 前端初始化交互脚本。
|
||||
//!
|
||||
//! 可运行代码块(markdown 围栏 ` ```lang runnable `)在服务端渲染为带
|
||||
//! `data-runnable` / `data-lang` / `data-overrides` / `data-source` 的 `<pre>`。
|
||||
//! 本组件在渲染前把 `content_html` 拆成片段序列:普通 HTML 文本片段 +
|
||||
//! [`crate::components::code_runner::CodeRunner`] 组件,使可运行块作为
|
||||
//! Dioxus vdom 内的一等元素渲染(而非手动篡改 DOM,避免 hydration 冲突)。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::code_runner::CodeRunner;
|
||||
use crate::infra::runner_config::ResourceLimits;
|
||||
|
||||
/// 内容片段:普通 HTML 文本,或一个可运行代码块。
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
enum ContentFragment {
|
||||
/// 原始 HTML 片段(含语法高亮等),直接以 `dangerous_inner_html` 渲染。
|
||||
Html(String),
|
||||
/// 可运行代码块:语言、源码、可选资源覆盖。
|
||||
Runnable {
|
||||
lang: String,
|
||||
source: String,
|
||||
overrides: Option<ResourceLimits>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 把服务端渲染的文章 HTML 拆成 `Html` / `Runnable` 片段序列。
|
||||
///
|
||||
/// 仅识别带 `data-runnable="true"` 的 `<pre>`;其余内容原样作为 Html 片段返回。
|
||||
/// HTML 实体(`"` `'` `&` `<` `>`)会被解码还原为原始字符。
|
||||
fn split_content_fragments(html: &str) -> Vec<ContentFragment> {
|
||||
let mut fragments = Vec::new();
|
||||
let mut rest = html;
|
||||
|
||||
while let Some(start) = rest.find(r#"<pre data-runnable="true""#) {
|
||||
// start 之前的内容作为 Html 片段(非空才推入)。
|
||||
let (head, tail) = rest.split_at(start);
|
||||
if !head.trim().is_empty() {
|
||||
fragments.push(ContentFragment::Html(head.to_string()));
|
||||
}
|
||||
|
||||
// 找到对应 </pre> 闭合。
|
||||
let Some(end_offset) = tail.find("</pre>") else {
|
||||
// 缺失闭合:剩余整体作为 Html 片段兜底,避免丢内容。
|
||||
fragments.push(ContentFragment::Html(tail.to_string()));
|
||||
rest = "";
|
||||
break;
|
||||
};
|
||||
let pre_block = &tail[..end_offset + "</pre>".len()];
|
||||
rest = &tail[end_offset + "</pre>".len()..];
|
||||
|
||||
// 从 pre_block 提取属性。
|
||||
let lang = extract_attr(pre_block, "data-lang").unwrap_or_default();
|
||||
let overrides = extract_attr(pre_block, "data-overrides")
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| serde_json::from_str::<ResourceLimits>(&s).ok());
|
||||
let source = extract_attr(pre_block, "data-source").unwrap_or_default();
|
||||
|
||||
fragments.push(ContentFragment::Runnable {
|
||||
lang,
|
||||
source,
|
||||
overrides,
|
||||
});
|
||||
}
|
||||
|
||||
if !rest.trim().is_empty() {
|
||||
fragments.push(ContentFragment::Html(rest.to_string()));
|
||||
}
|
||||
|
||||
fragments
|
||||
}
|
||||
|
||||
/// 从 HTML 片段中提取首个 `name="value"` 属性值,并解码 HTML 实体。
|
||||
/// 仅在单个 `<pre>` 块内查找,足够本场景使用。
|
||||
fn extract_attr(block: &str, name: &str) -> Option<String> {
|
||||
let needle = format!("{name}=\"");
|
||||
let start = block.find(&needle)? + needle.len();
|
||||
let rest = &block[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(decode_html_entities(&rest[..end]))
|
||||
}
|
||||
|
||||
/// 解码本场景出现的 HTML 实体(属性值经 escape_html 转义产生)。
|
||||
fn decode_html_entities(s: &str) -> String {
|
||||
s.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// 读取 `window` 上的可选全局函数并调用;函数未定义/为 null 时静默跳过。
|
||||
///
|
||||
/// 替代 `js_sys::eval("if(window.__x) window.__x(...)")` 字符串拼贴模式:用
|
||||
@ -31,12 +119,17 @@ fn invoke_optional_global(window: &web_sys::Window, name: &str, args: &[wasm_bin
|
||||
/// - `content_html`:服务端渲染的文章 HTML 字符串
|
||||
///
|
||||
/// 关键行为:
|
||||
/// - 把可运行代码块拆成 [`CodeRunner`] 组件穿插渲染,其余 HTML 片段照旧。
|
||||
/// - 在 `target_arch = "wasm32"` 环境下调用 `window.__initPostContent` 初始化代码块
|
||||
/// 复制按钮(`yggdrasil-core.js` 已由 `Dioxus.toml` 全局注入)。
|
||||
/// 灯箱(图片灯箱 + 懒加载)改由 `Dioxus.toml` 全局注入 `lightbox.js`,
|
||||
/// 这里仅设置其初始化配置 `__lightboxSelectors` 并兜底调用。
|
||||
#[component]
|
||||
pub fn PostContent(content_html: String) -> Element {
|
||||
// 拆分片段:use_memo 避免每次渲染重复解析。
|
||||
let fragments =
|
||||
use_memo(move || split_content_fragments(&content_html));
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use_effect(move || {
|
||||
let window = web_sys::window().unwrap();
|
||||
@ -57,9 +150,99 @@ pub fn PostContent(content_html: String) -> Element {
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "post-content md-content",
|
||||
dangerous_inner_html: "{content_html}",
|
||||
div { class: "post-content md-content",
|
||||
for (i, fragment) in fragments.read().iter().enumerate() {
|
||||
{match fragment {
|
||||
ContentFragment::Html(html) => rsx! {
|
||||
div { key: "html-{i}", dangerous_inner_html: "{html}" }
|
||||
},
|
||||
ContentFragment::Runnable { lang, source, overrides } => rsx! {
|
||||
CodeRunner {
|
||||
key: "runner-{i}",
|
||||
source: source.clone(),
|
||||
language: lang.clone(),
|
||||
overrides: overrides.clone(),
|
||||
}
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_plain_html_has_no_runnable() {
|
||||
let frags = split_content_fragments("<p>hello</p>");
|
||||
assert_eq!(frags.len(), 1);
|
||||
assert!(matches!(frags[0], ContentFragment::Html(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_extracts_runnable_block() {
|
||||
let html = r#"<p>intro</p><pre data-runnable="true" data-lang="python" data-overrides="" data-source="print('hi')"><code class="language-python">print('hi')</code></pre><p>outro</p>"#;
|
||||
let frags = split_content_fragments(html);
|
||||
// intro + runnable + outro = 3
|
||||
assert_eq!(frags.len(), 3);
|
||||
match &frags[1] {
|
||||
ContentFragment::Runnable {
|
||||
lang,
|
||||
source,
|
||||
overrides,
|
||||
} => {
|
||||
assert_eq!(lang, "python");
|
||||
assert_eq!(source, "print('hi')");
|
||||
assert!(overrides.is_none());
|
||||
}
|
||||
other => panic!("expected Runnable, got {other:?} (Html)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_parses_overrides_json() {
|
||||
let html = r#"<pre data-runnable="true" data-lang="node" data-overrides="{"timeout_secs":10,"memory_mb"512,"allow_network":false,"cpu_cores":1.0,"output_bytes":1024}" data-source="console.log(1)"><code>x</code></pre>"#;
|
||||
// 注意:上面 overrides 故意写成畸形 JSON(缺冒号)→ 解析失败 → overrides 为 None
|
||||
let frags = split_content_fragments(html);
|
||||
assert_eq!(frags.len(), 1);
|
||||
match &frags[0] {
|
||||
ContentFragment::Runnable { overrides, .. } => {
|
||||
assert!(overrides.is_none(), "畸形 JSON 应解析失败为 None");
|
||||
}
|
||||
_ => panic!("expected Runnable"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_valid_overrides_json() {
|
||||
let html = r#"<pre data-runnable="true" data-lang="node" data-overrides="{"timeout_secs":10,"memory_mb":512,"allow_network":false,"cpu_cores":1.0,"output_bytes":1024}" data-source="console.log(1)"><code>x</code></pre>"#;
|
||||
let frags = split_content_fragments(html);
|
||||
match &frags[0] {
|
||||
ContentFragment::Runnable { overrides, .. } => {
|
||||
let ov = overrides.as_ref().expect("overrides 应解析成功");
|
||||
assert_eq!(ov.timeout_secs, 10);
|
||||
assert_eq!(ov.memory_mb, 512);
|
||||
}
|
||||
_ => panic!("expected Runnable"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_unclosed_pre_falls_back_to_html() {
|
||||
let html = r#"<pre data-runnable="true" data-lang="python""#;
|
||||
let frags = split_content_fragments(html);
|
||||
assert_eq!(frags.len(), 1);
|
||||
assert!(matches!(frags[0], ContentFragment::Html(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_html_entities_roundtrip() {
|
||||
assert_eq!(decode_html_entities("print('hi')"), "print('hi')");
|
||||
assert_eq!(
|
||||
decode_html_entities(""<>&"),
|
||||
"\"<>&"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user