Skip to main content

yggdrasil/components/post/
post_content.rs

1//! 文章内容组件
2//!
3//! 渲染由服务端生成的文章 HTML 内容,并在 WASM 前端初始化交互脚本。
4//!
5//! 可运行代码块(markdown 围栏 ` ```lang runnable `)在服务端渲染为带
6//! `data-runnable` / `data-lang` / `data-overrides` / `data-source` 的 `<pre>`。
7//! 本组件在渲染前把 `content_html` 拆成片段序列:普通 HTML 文本片段 +
8//! [`crate::components::code_runner::CodeRunner`] 组件,使可运行块作为
9//! Leptos 视图内的一等元素渲染(而非手动篡改 DOM,避免 hydration 冲突)。
10
11use 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/// 内容片段:普通 HTML 文本,或一个可运行代码块。
24#[derive(Clone, PartialEq, Debug)]
25enum ContentFragment {
26    /// 原始 HTML 片段(含语法高亮等),直接以 `inner_html` 渲染。
27    Html(String),
28    /// 可运行代码块:语言、源码、可选资源覆盖,以及服务端高亮好的代码 HTML
29    /// (编辑器挂载前原样显示,SSR 与 hydration 输出一致)。
30    Runnable {
31        lang: String,
32        source: String,
33        overrides: Option<ResourceLimits>,
34        highlighted: String,
35    },
36}
37
38/// 正文按可运行代码块切开后的原文段:`Html` 是两个可运行块之间的原文(只含空白的段跳过),
39/// `Runnable` 是整个 `<pre data-runnable="true" …>…</pre>`。
40enum Segment<'a> {
41    Html(&'a str),
42    Runnable(&'a str),
43}
44
45/// 仅识别带 `data-runnable="true"` 的 `<pre>`;缺少 `</pre>` 时剩余内容整体作为 Html 段。
46fn 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            // 缺失闭合:剩余整体作为 Html 段兜底,避免丢内容。
57            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
71/// 把服务端渲染的文章 HTML 拆成 `Html` / `Runnable` 片段序列。
72///
73/// HTML 实体(`&quot;` `&#x27;` `&amp;` `&lt;` `&gt;`)会被解码还原为原始字符。
74fn 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
91/// 水合数据里替代已渲染 HTML 的占位:非空白,切片时仍占一个 `Html` 片段;`inner_html`
92/// 水合时不改写服务端 DOM,只记下这个值。
93pub const HYDRATION_PLACEHOLDER: &str = "<!---->";
94
95/// 正文的水合骨架:每个 `Html` 段换成 [`HYDRATION_PLACEHOLDER`],可运行块原样保留。
96/// 骨架切出的片段序列与原文一一对应(种类、顺序、可运行块的字段都相同),水合时
97/// [`PostContent`] 据此建出与 SSR 相同的组件树,而正文不必再随水合数据下发一遍。
98pub 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
108/// `<pre ...><code ...>内容</code></pre>` 中 `<code>` 的内部 HTML;结构不符时为空。
109fn 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
121// ============================================================================
122// 片段解析缓存(P4.2)。
123//
124// 组件函数体虽只运行一次,但同一进程内 SSR 会反复渲染同一批文章,浏览器切页也会重复
125// 解析同一篇长文——整篇 content_html 的重新扫描并分配全部片段字符串,长文 + 多运行块时
126// 是明显的主线程尖峰。这里按内容哈希做进程内有界缓存:
127// - 命中需通过全文相等核对(条目保留 Arc<str> 原文),哈希碰撞不会错渲染。
128// - 有界(8 篇,插入序淘汰):服务端 SSR 渲染不同文章、浏览器切页都不会无界
129//   增长;缓存只跳过解析,输出逐字节相同。
130// ============================================================================
131const 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
143/// 带内容缓存的片段解析:内容相同(含更新)时复用上次解析结果。
144fn 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
172/// 从 HTML 片段中提取首个 `name="value"` 属性值,并解码 HTML 实体。
173/// 仅在单个 `<pre>` 块内查找,足够本场景使用。
174fn 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
182/// 解码本场景出现的 HTML 实体(属性值经 escape_html 转义产生)。
183fn decode_html_entities(s: &str) -> String {
184    s.replace("&quot;", "\"")
185        .replace("&#x27;", "'")
186        .replace("&#39;", "'")
187        .replace("&lt;", "<")
188        .replace("&gt;", ">")
189        .replace("&amp;", "&")
190}
191
192/// 文章内容组件。
193///
194/// Props:
195/// - `content_html`:服务端渲染的文章 HTML 字符串
196/// - `scope_id`:可选的局部正文 ID(省略即不传,不要传 `Some(..)`);图鉴只增强该实例,
197///   正式页面使用默认范围。
198///
199/// 关键行为:
200/// - 把可运行代码块拆成 [`CodeRunner`] 组件穿插渲染,其余 HTML 片段照旧。
201/// - 在 `target_arch = "wasm32"` 环境下调用 `window.__initPostContent` 初始化代码块
202///   复制按钮;P5 §8.2 起正文增强在 `yggdrasil-enhance.js`、灯箱在 `lightbox.js`,
203///   均由本组件按需声明注入(见 [`crate::components::enhance_assets`]),
204///   这里只经 `bridges::globals` 调用;脚本未就绪时调用入队,脚本尾部补执行。
205///
206/// 组件函数体只运行一次:同一实例内 `content_html` 不会更新,上下篇切换须由调用方按
207/// slug 重建本组件(片段解析按内容哈希缓存,重建不重复解析)。
208#[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    // 正文挂载后初始化交互脚本:不读取任何信号,只运行一次。
216    #[cfg(target_arch = "wasm32")]
217    {
218        Effect::new(move || {
219            // 图鉴实例只增强自己的正文,正式页面仍按原选择器处理。
220            let selector =
221                scope_id.map_or_else(|| ".post-content".to_string(), |id| format!("#{id}"));
222            globals::init_post_content(&selector);
223
224            // Mermaid 图表已由服务端渲染成浅/深两份 SVG(CSS 按主题切换),这里只绑定点击放大。
225            globals::init_mermaid(&selector);
226
227            // lightbox.js 未加载时调用入队,其 IIFE 尾部补执行。
228            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            // 安装 hash 锚点点击拦截器(幂等,PostContent 多次挂载也安全)。
235            globals::init_anchor_click();
236
237            // 内容挂载后若 URL 带 hash,滚动到对应标题:首屏渲染骨架屏阶段标题 DOM 缺失,
238            // 浏览器原生 fragment-scroll 失效,此处标题已就绪,补一次。
239            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                // i 是片段序列中的确定性索引(来自纯函数 split_content_fragments 对同一
263                // content_html 的解析),SSR 与 hydration 一致,用作容器 id 后缀保证
264                // hydration 时 CodeMirror 能找到 SSR 渲染的容器。
265                <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        // P5 §8.2:正文增强与灯箱按需注入。SSR 直达烤进 head 带 defer,先于
279        // hydration;SPA 首跳脚本晚于 Effect 时由增强调用队列兜底。
280        <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(&#x27;hi&#x27;)"><code class="language-python">print('hi')</code></pre><p>outro</p>"#;
302        let frags = split_content_fragments(html);
303        // intro + runnable + outro = 3
304        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="{&quot;timeout_secs&quot;:10,&quot;memory_mb&quot;512,&quot;allow_network&quot;:false,&quot;cpu_cores&quot;:1.0,&quot;output_bytes&quot;:1024}" data-source="console.log(1)"><code>x</code></pre>"#;
324        // 注意:上面 overrides 故意写成畸形 JSON(缺冒号)→ 解析失败 → overrides 为 None
325        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="{&quot;timeout_secs&quot;:10,&quot;memory_mb&quot;:512,&quot;allow_network&quot;:false,&quot;cpu_cores&quot;:1.0,&quot;output_bytes&quot;: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(&#x27;hi&#x27;)"), "print('hi')");
360        assert_eq!(decode_html_entities("&quot;&lt;&gt;&amp;"), "\"<>&");
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        // 超出容量后旧条目被淘汰;重新解析的输出与首次逐值一致。
371        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    /// 水合骨架切出的片段与原文一一对应:Html 换成占位,可运行块字段不变。
379    #[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(&#x27;{lang}&#x27;)"><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}