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]
110pub fn PostContent(
111 content_html: String,
112 #[props(default)] scope_id: Option<&'static str>,
113) -> Element {
114 let fragments = split_content_fragments(&content_html);
123
124 #[cfg(target_arch = "wasm32")]
129 let resolved_theme = crate::theme::use_resolved_theme();
130
131 #[cfg(target_arch = "wasm32")]
136 let mut did_scroll = use_signal(|| false);
137
138 #[cfg(target_arch = "wasm32")]
139 use_effect(move || {
140 let window = web_sys::window()
141 .expect("post_content use_effect 仅在 WASM 浏览器上下文执行:无 window");
142
143 let selector = scope_id.map_or_else(|| ".post-content".to_string(), |id| format!("#{id}"));
145 invoke_optional_global(&window, "__initPostContent", &[selector.clone().into()]);
148
149 let theme_str: String = if resolved_theme() == crate::theme::ResolvedTheme::Dark {
153 "dark".into()
154 } else {
155 "light".into()
156 };
157 let transitioning = window
165 .document()
166 .and_then(|d| d.document_element())
167 .map(|el| el.class_list().contains("is-theme-transitioning"))
168 .unwrap_or(false);
169 if !transitioning {
170 invoke_optional_global(
171 &window,
172 "__initMermaid",
173 &[selector.clone().into(), theme_str.into()],
174 );
175 }
176
177 let selectors = js_sys::Array::new();
181 selectors.push(&selector.into());
182 if scope_id.is_none() {
183 selectors.push(&".entry-cover".into());
184 }
185 let selectors_val = js_sys::Object::from(selectors).into();
186 if scope_id.is_none() {
187 let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
188 }
189 invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
190
191 invoke_optional_global(&window, "__initAnchorClick", &[]);
196
197 if scope_id.is_none() && !did_scroll() {
205 did_scroll.set(true);
206 invoke_optional_global(&window, "__scrollToHash", &[]);
207 }
208 });
209
210 #[cfg(target_arch = "wasm32")]
211 use_drop(move || {
212 if let (Some(window), Some(owner)) = (web_sys::window(), scope_id) {
213 invoke_optional_global(&window, "__closeLightboxFor", &[owner.into()]);
214 }
215 });
216
217 rsx! {
218 div { id: scope_id, "data-showcase-lightbox-owner": scope_id, class: "post-content md-content",
219 for (i, fragment) in fragments.iter().enumerate() {
220 {
221 match fragment {
222 ContentFragment::Html(html) => rsx! {
223 div { key: "html-{i}", dangerous_inner_html: "{html}" }
224 },
225 ContentFragment::Runnable { lang, source, overrides } => rsx! {
226 CodeRunner {
227 key: "runner-{i}",
228 source: source.clone(),
229 language: lang.clone(),
230 overrides: overrides.clone(),
231 instance_id: i,
235 }
236 },
237 }
238 }
239 }
240 }
241 }
242}
243
244#[cfg(all(test, feature = "server"))]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn split_plain_html_has_no_runnable() {
250 let frags = split_content_fragments("<p>hello</p>");
251 assert_eq!(frags.len(), 1);
252 assert!(matches!(frags[0], ContentFragment::Html(_)));
253 }
254
255 #[test]
256 fn split_extracts_runnable_block() {
257 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>"#;
258 let frags = split_content_fragments(html);
259 assert_eq!(frags.len(), 3);
261 match &frags[1] {
262 ContentFragment::Runnable {
263 lang,
264 source,
265 overrides,
266 } => {
267 assert_eq!(lang, "python");
268 assert_eq!(source, "print('hi')");
269 assert!(overrides.is_none());
270 }
271 other => panic!("expected Runnable, got {other:?} (Html)"),
272 }
273 }
274
275 #[test]
276 fn split_parses_overrides_json() {
277 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>"#;
278 let frags = split_content_fragments(html);
280 assert_eq!(frags.len(), 1);
281 match &frags[0] {
282 ContentFragment::Runnable { overrides, .. } => {
283 assert!(overrides.is_none(), "畸形 JSON 应解析失败为 None");
284 }
285 _ => panic!("expected Runnable"),
286 }
287 }
288
289 #[test]
290 fn split_valid_overrides_json() {
291 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>"#;
292 let frags = split_content_fragments(html);
293 match &frags[0] {
294 ContentFragment::Runnable { overrides, .. } => {
295 let ov = overrides.as_ref().expect("overrides 应解析成功");
296 assert_eq!(ov.timeout_secs, 10);
297 assert_eq!(ov.memory_mb, 512);
298 }
299 _ => panic!("expected Runnable"),
300 }
301 }
302
303 #[test]
304 fn split_unclosed_pre_falls_back_to_html() {
305 let html = r#"<pre data-runnable="true" data-lang="python""#;
306 let frags = split_content_fragments(html);
307 assert_eq!(frags.len(), 1);
308 assert!(matches!(frags[0], ContentFragment::Html(_)));
309 }
310
311 #[test]
312 fn decode_html_entities_roundtrip() {
313 assert_eq!(decode_html_entities("print('hi')"), "print('hi')");
314 assert_eq!(decode_html_entities(""<>&"), "\"<>&");
315 }
316}