yggdrasil/components/post/
post_content.rs1use dioxus::prelude::*;
12
13use crate::components::code_runner::CodeRunner;
14use crate::infra::runner_config::ResourceLimits;
15#[cfg(target_arch = "wasm32")]
16use crate::utils::js::invoke_optional_global;
17
18#[derive(Clone, PartialEq, Debug)]
20enum ContentFragment {
21 Html(String),
23 Runnable {
25 lang: String,
26 source: String,
27 overrides: Option<ResourceLimits>,
28 },
29}
30
31fn split_content_fragments(html: &str) -> Vec<ContentFragment> {
36 let mut fragments = Vec::new();
37 let mut rest = html;
38
39 while let Some(start) = rest.find(r#"<pre data-runnable="true""#) {
40 let (head, tail) = rest.split_at(start);
42 if !head.trim().is_empty() {
43 fragments.push(ContentFragment::Html(head.to_string()));
44 }
45
46 let Some(end_offset) = tail.find("</pre>") else {
48 fragments.push(ContentFragment::Html(tail.to_string()));
50 rest = "";
51 break;
52 };
53 let pre_block = &tail[..end_offset + "</pre>".len()];
54 rest = &tail[end_offset + "</pre>".len()..];
55
56 let lang = extract_attr(pre_block, "data-lang").unwrap_or_default();
58 let overrides = extract_attr(pre_block, "data-overrides")
59 .filter(|s| !s.is_empty())
60 .and_then(|s| serde_json::from_str::<ResourceLimits>(&s).ok());
61 let source = extract_attr(pre_block, "data-source").unwrap_or_default();
62
63 fragments.push(ContentFragment::Runnable {
64 lang,
65 source,
66 overrides,
67 });
68 }
69
70 if !rest.trim().is_empty() {
71 fragments.push(ContentFragment::Html(rest.to_string()));
72 }
73
74 fragments
75}
76
77fn extract_attr(block: &str, name: &str) -> Option<String> {
80 let needle = format!("{name}=\"");
81 let start = block.find(&needle)? + needle.len();
82 let rest = &block[start..];
83 let end = rest.find('"')?;
84 Some(decode_html_entities(&rest[..end]))
85}
86
87fn decode_html_entities(s: &str) -> String {
89 s.replace(""", "\"")
90 .replace("'", "'")
91 .replace("'", "'")
92 .replace("<", "<")
93 .replace(">", ">")
94 .replace("&", "&")
95}
96
97#[component]
109pub fn PostContent(content_html: String) -> Element {
110 let fragments = split_content_fragments(&content_html);
119
120 #[cfg(target_arch = "wasm32")]
125 let resolved_theme = crate::theme::use_resolved_theme();
126
127 #[cfg(target_arch = "wasm32")]
132 let mut did_scroll = use_signal(|| false);
133
134 #[cfg(target_arch = "wasm32")]
135 use_effect(move || {
136 let window = web_sys::window()
137 .expect("post_content use_effect 仅在 WASM 浏览器上下文执行:无 window");
138
139 invoke_optional_global(&window, "__initPostContent", &[".post-content".into()]);
142
143 let theme_str: String = if resolved_theme() == crate::theme::ResolvedTheme::Dark {
147 "dark".into()
148 } else {
149 "light".into()
150 };
151 let transitioning = window
159 .document()
160 .and_then(|d| d.document_element())
161 .map(|el| el.class_list().contains("is-theme-transitioning"))
162 .unwrap_or(false);
163 if !transitioning {
164 invoke_optional_global(
165 &window,
166 "__initMermaid",
167 &[".post-content".into(), theme_str.into()],
168 );
169 }
170
171 let selectors = js_sys::Array::new();
175 selectors.push(&".post-content".into());
176 selectors.push(&".entry-cover".into());
177 let selectors_val = js_sys::Object::from(selectors).into();
178 let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
179 invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
180
181 invoke_optional_global(&window, "__initAnchorClick", &[]);
186
187 if !did_scroll() {
195 did_scroll.set(true);
196 invoke_optional_global(&window, "__scrollToHash", &[]);
197 }
198 });
199
200 rsx! {
201 div { class: "post-content md-content",
202 for (i, fragment) in fragments.iter().enumerate() {
203 {
204 match fragment {
205 ContentFragment::Html(html) => rsx! {
206 div { key: "html-{i}", dangerous_inner_html: "{html}" }
207 },
208 ContentFragment::Runnable { lang, source, overrides } => rsx! { CodeRunner {
210 key: "runner-{i}",
211 source: source.clone(),
212 language: lang.clone(),
213 overrides: overrides.clone(),
214 instance_id: i,
218 }
219 },
220 }
221 }
222 }
223 }
224 }
225}
226
227#[cfg(all(test, feature = "server"))]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn split_plain_html_has_no_runnable() {
233 let frags = split_content_fragments("<p>hello</p>");
234 assert_eq!(frags.len(), 1);
235 assert!(matches!(frags[0], ContentFragment::Html(_)));
236 }
237
238 #[test]
239 fn split_extracts_runnable_block() {
240 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>"#;
241 let frags = split_content_fragments(html);
242 assert_eq!(frags.len(), 3);
244 match &frags[1] {
245 ContentFragment::Runnable {
246 lang,
247 source,
248 overrides,
249 } => {
250 assert_eq!(lang, "python");
251 assert_eq!(source, "print('hi')");
252 assert!(overrides.is_none());
253 }
254 other => panic!("expected Runnable, got {other:?} (Html)"),
255 }
256 }
257
258 #[test]
259 fn split_parses_overrides_json() {
260 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>"#;
261 let frags = split_content_fragments(html);
263 assert_eq!(frags.len(), 1);
264 match &frags[0] {
265 ContentFragment::Runnable { overrides, .. } => {
266 assert!(overrides.is_none(), "畸形 JSON 应解析失败为 None");
267 }
268 _ => panic!("expected Runnable"),
269 }
270 }
271
272 #[test]
273 fn split_valid_overrides_json() {
274 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>"#;
275 let frags = split_content_fragments(html);
276 match &frags[0] {
277 ContentFragment::Runnable { overrides, .. } => {
278 let ov = overrides.as_ref().expect("overrides 应解析成功");
279 assert_eq!(ov.timeout_secs, 10);
280 assert_eq!(ov.memory_mb, 512);
281 }
282 _ => panic!("expected Runnable"),
283 }
284 }
285
286 #[test]
287 fn split_unclosed_pre_falls_back_to_html() {
288 let html = r#"<pre data-runnable="true" data-lang="python""#;
289 let frags = split_content_fragments(html);
290 assert_eq!(frags.len(), 1);
291 assert!(matches!(frags[0], ContentFragment::Html(_)));
292 }
293
294 #[test]
295 fn decode_html_entities_roundtrip() {
296 assert_eq!(decode_html_entities("print('hi')"), "print('hi')");
297 assert_eq!(decode_html_entities(""<>&"), "\"<>&");
298 }
299}