yggdrasil/components/post/
post_content.rs1use std::collections::VecDeque;
12use std::hash::{Hash, Hasher};
13use std::sync::{Arc, Mutex, OnceLock};
14
15use leptos::prelude::*;
16
17#[cfg(target_arch = "wasm32")]
18use crate::bridges::globals;
19use crate::components::code_runner::CodeRunner;
20use crate::components::enhance_assets::{EnhanceScript, LightboxAssets};
21use crate::infra::runner_config::ResourceLimits;
22
23#[derive(Clone, PartialEq, Debug)]
25enum ContentFragment {
26 Html(String),
28 Runnable {
31 lang: String,
32 source: String,
33 overrides: Option<ResourceLimits>,
34 highlighted: String,
35 },
36}
37
38enum Segment<'a> {
41 Html(&'a str),
42 Runnable(&'a str),
43}
44
45fn segments(html: &str) -> Vec<Segment<'_>> {
47 let mut segments = Vec::new();
48 let mut rest = html;
49
50 while let Some(start) = rest.find(r#"<pre data-runnable="true""#) {
51 let (head, tail) = rest.split_at(start);
52 if !head.trim().is_empty() {
53 segments.push(Segment::Html(head));
54 }
55 let Some(end_offset) = tail.find("</pre>") else {
56 segments.push(Segment::Html(tail));
58 return segments;
59 };
60 let end = end_offset + "</pre>".len();
61 segments.push(Segment::Runnable(&tail[..end]));
62 rest = &tail[end..];
63 }
64
65 if !rest.trim().is_empty() {
66 segments.push(Segment::Html(rest));
67 }
68 segments
69}
70
71fn split_content_fragments(html: &str) -> Vec<ContentFragment> {
75 segments(html)
76 .into_iter()
77 .map(|segment| match segment {
78 Segment::Html(html) => ContentFragment::Html(html.to_string()),
79 Segment::Runnable(pre_block) => ContentFragment::Runnable {
80 lang: extract_attr(pre_block, "data-lang").unwrap_or_default(),
81 source: extract_attr(pre_block, "data-source").unwrap_or_default(),
82 overrides: extract_attr(pre_block, "data-overrides")
83 .filter(|s| !s.is_empty())
84 .and_then(|s| serde_json::from_slice::<ResourceLimits>(s.as_bytes()).ok()),
85 highlighted: code_inner_html(pre_block).to_string(),
86 },
87 })
88 .collect()
89}
90
91pub const HYDRATION_PLACEHOLDER: &str = "<!---->";
94
95pub fn hydration_skeleton(content_html: &str) -> String {
99 segments(content_html)
100 .into_iter()
101 .map(|segment| match segment {
102 Segment::Html(_) => HYDRATION_PLACEHOLDER,
103 Segment::Runnable(pre_block) => pre_block,
104 })
105 .collect()
106}
107
108fn code_inner_html(block: &str) -> &str {
110 let Some(code_start) = block.find("<code") else {
111 return "";
112 };
113 let after_tag = &block[code_start..];
114 let Some(open_end) = after_tag.find('>') else {
115 return "";
116 };
117 let inner = &after_tag[open_end + 1..];
118 inner.rfind("</code>").map_or("", |end| &inner[..end])
119}
120
121const FRAGMENT_CACHE_CAP: usize = 8;
132
133struct FragmentCacheEntry {
134 content: Arc<str>,
135 fragments: Arc<Vec<ContentFragment>>,
136}
137
138fn fragment_cache() -> &'static Mutex<VecDeque<(u64, FragmentCacheEntry)>> {
139 static CACHE: OnceLock<Mutex<VecDeque<(u64, FragmentCacheEntry)>>> = OnceLock::new();
140 CACHE.get_or_init(|| Mutex::new(VecDeque::with_capacity(FRAGMENT_CACHE_CAP)))
141}
142
143fn split_content_fragments_cached(html: &str) -> Arc<Vec<ContentFragment>> {
145 let key = {
146 let mut hasher = std::collections::hash_map::DefaultHasher::new();
147 html.hash(&mut hasher);
148 hasher.finish()
149 };
150 {
151 let cache = fragment_cache().lock().unwrap_or_else(|e| e.into_inner());
152 if let Some((_, entry)) = cache
153 .iter()
154 .find(|(k, entry)| *k == key && &*entry.content == html)
155 {
156 return entry.fragments.clone();
157 }
158 }
159 let entry = FragmentCacheEntry {
160 content: Arc::from(html),
161 fragments: Arc::new(split_content_fragments(html)),
162 };
163 let fragments = entry.fragments.clone();
164 let mut cache = fragment_cache().lock().unwrap_or_else(|e| e.into_inner());
165 if cache.len() >= FRAGMENT_CACHE_CAP {
166 cache.pop_front();
167 }
168 cache.push_back((key, entry));
169 fragments
170}
171
172fn extract_attr(block: &str, name: &str) -> Option<String> {
175 let needle = format!("{name}=\"");
176 let start = block.find(&needle)? + needle.len();
177 let rest = &block[start..];
178 let end = rest.find('"')?;
179 Some(decode_html_entities(&rest[..end]))
180}
181
182fn decode_html_entities(s: &str) -> String {
184 s.replace(""", "\"")
185 .replace("'", "'")
186 .replace("'", "'")
187 .replace("<", "<")
188 .replace(">", ">")
189 .replace("&", "&")
190}
191
192#[component]
209pub fn PostContent(
210 #[prop(into)] content_html: String,
211 #[prop(optional)] scope_id: Option<&'static str>,
212) -> impl IntoView {
213 let fragments = split_content_fragments_cached(&content_html);
214
215 #[cfg(target_arch = "wasm32")]
217 {
218 Effect::new(move || {
219 let selector =
221 scope_id.map_or_else(|| ".post-content".to_string(), |id| format!("#{id}"));
222 globals::init_post_content(&selector);
223
224 globals::init_mermaid(&selector);
226
227 let mut lightbox = vec![selector.as_str()];
229 if scope_id.is_none() {
230 lightbox.push(".entry-cover");
231 }
232 globals::init_lightbox(&lightbox);
233
234 globals::init_anchor_click();
236
237 if scope_id.is_none() {
240 globals::scroll_to_hash();
241 }
242 });
243
244 on_cleanup(move || {
245 if let Some(owner) = scope_id {
246 globals::close_lightbox_for(owner);
247 }
248 });
249 }
250
251 let body = fragments
252 .iter()
253 .enumerate()
254 .map(|(i, fragment)| match fragment {
255 ContentFragment::Html(html) => view! { <div inner_html=html.clone()></div> }.into_any(),
256 ContentFragment::Runnable {
257 lang,
258 source,
259 overrides,
260 highlighted,
261 } => view! {
262 <CodeRunner
266 source=source.clone()
267 language=lang.clone()
268 overrides=overrides.clone()
269 highlighted=Some(highlighted.clone()).filter(|h| !h.is_empty())
270 instance_id=i
271 />
272 }
273 .into_any(),
274 })
275 .collect_view();
276
277 view! {
278 <EnhanceScript/>
281 <LightboxAssets/>
282 <div id=scope_id data-showcase-lightbox-owner=scope_id class="post-content md-content">
283 {body}
284 </div>
285 }
286}
287
288#[cfg(all(test, feature = "server"))]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn split_plain_html_has_no_runnable() {
294 let frags = split_content_fragments("<p>hello</p>");
295 assert_eq!(frags.len(), 1);
296 assert!(matches!(frags[0], ContentFragment::Html(_)));
297 }
298
299 #[test]
300 fn split_extracts_runnable_block() {
301 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>"#;
302 let frags = split_content_fragments(html);
303 assert_eq!(frags.len(), 3);
305 match &frags[1] {
306 ContentFragment::Runnable {
307 lang,
308 source,
309 overrides,
310 highlighted,
311 } => {
312 assert_eq!(lang, "python");
313 assert_eq!(source, "print('hi')");
314 assert!(overrides.is_none());
315 assert_eq!(highlighted, "print('hi')");
316 }
317 other => panic!("expected Runnable, got {other:?} (Html)"),
318 }
319 }
320
321 #[test]
322 fn split_parses_overrides_json() {
323 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>"#;
324 let frags = split_content_fragments(html);
326 assert_eq!(frags.len(), 1);
327 match &frags[0] {
328 ContentFragment::Runnable { overrides, .. } => {
329 assert!(overrides.is_none(), "畸形 JSON 应解析失败为 None");
330 }
331 _ => panic!("expected Runnable"),
332 }
333 }
334
335 #[test]
336 fn split_valid_overrides_json() {
337 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>"#;
338 let frags = split_content_fragments(html);
339 match &frags[0] {
340 ContentFragment::Runnable { overrides, .. } => {
341 let ov = overrides.as_ref().expect("overrides 应解析成功");
342 assert_eq!(ov.timeout_secs, 10);
343 assert_eq!(ov.memory_mb, 512);
344 }
345 _ => panic!("expected Runnable"),
346 }
347 }
348
349 #[test]
350 fn split_unclosed_pre_falls_back_to_html() {
351 let html = r#"<pre data-runnable="true" data-lang="python""#;
352 let frags = split_content_fragments(html);
353 assert_eq!(frags.len(), 1);
354 assert!(matches!(frags[0], ContentFragment::Html(_)));
355 }
356
357 #[test]
358 fn decode_html_entities_roundtrip() {
359 assert_eq!(decode_html_entities("print('hi')"), "print('hi')");
360 assert_eq!(decode_html_entities(""<>&"), "\"<>&");
361 }
362
363 #[test]
364 fn fragment_cache_follows_content_changes() {
365 let a = split_content_fragments_cached("<p>alpha</p>");
366 let a2 = split_content_fragments_cached("<p>alpha</p>");
367 assert!(Arc::ptr_eq(&a, &a2), "相同内容应命中缓存复用同一解析结果");
368 let b = split_content_fragments_cached("<p>beta</p>");
369 assert!(!Arc::ptr_eq(&a, &b), "不同内容不得复用缓存");
370 for i in 0..(FRAGMENT_CACHE_CAP + 2) {
372 split_content_fragments_cached(&format!("<p>filler-{i}</p>"));
373 }
374 let a3 = split_content_fragments_cached("<p>alpha</p>");
375 assert_eq!(a3, a, "淘汰后重新解析输出应与首次一致");
376 }
377
378 #[test]
380 fn hydration_skeleton_keeps_fragment_structure() {
381 let runnable = |lang: &str| {
382 format!(
383 r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="" data-source="print('{lang}')"><code>{lang}</code></pre>"#
384 )
385 };
386 for html in [
387 "<h2 id=\"a\">标题</h2><p>只有正文</p>".to_string(),
388 format!(
389 "{}\n\n{}<p>中间</p> \n{}",
390 runnable("py"),
391 runnable("go"),
392 runnable("rs")
393 ),
394 format!("<p>开头</p>{}<p>结尾</p>", runnable("node")),
395 format!(
396 "<p>未闭合</p>{}<pre data-runnable=\"true\" data-lang=\"x\"",
397 runnable("bun")
398 ),
399 String::new(),
400 ] {
401 let expected: Vec<_> = split_content_fragments(&html)
402 .into_iter()
403 .map(|fragment| match fragment {
404 ContentFragment::Html(_) => {
405 ContentFragment::Html(HYDRATION_PLACEHOLDER.to_string())
406 }
407 runnable => runnable,
408 })
409 .collect();
410 let skeleton = hydration_skeleton(&html);
411 assert_eq!(split_content_fragments(&skeleton), expected, "{html}");
412 }
413 }
414}