//! 文章内容组件 //! //! 渲染由服务端生成的文章 HTML 内容,并在 WASM 前端初始化交互脚本。 //! //! 可运行代码块(markdown 围栏 ` ```lang runnable `)在服务端渲染为带 //! `data-runnable` / `data-lang` / `data-overrides` / `data-source` 的 `
`。
//! 本组件在渲染前把 `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,
    },
}

/// 把服务端渲染的文章 HTML 拆成 `Html` / `Runnable` 片段序列。
///
/// 仅识别带 `data-runnable="true"` 的 `
`;其余内容原样作为 Html 片段返回。
/// HTML 实体(`"` `'` `&` `<` `>`)会被解码还原为原始字符。
fn split_content_fragments(html: &str) -> Vec {
    let mut fragments = Vec::new();
    let mut rest = html;

    while let Some(start) = rest.find(r#"
 闭合。
        let Some(end_offset) = tail.find("
") else { // 缺失闭合:剩余整体作为 Html 片段兜底,避免丢内容。 fragments.push(ContentFragment::Html(tail.to_string())); rest = ""; break; }; let pre_block = &tail[..end_offset + "
".len()]; rest = &tail[end_offset + "
".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::(&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 实体。 /// 仅在单个 `
` 块内查找,足够本场景使用。
fn extract_attr(block: &str, name: &str) -> Option {
    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(...)")` 字符串拼贴模式:用
/// `Reflect::get` 取属性 + `Function::apply` 调用,无字符串求值,与 `tiptap_bridge`
/// 的类型化 extern 风格一致。
#[cfg(target_arch = "wasm32")]
fn invoke_optional_global(window: &web_sys::Window, name: &str, args: &[wasm_bindgen::JsValue]) {
    use wasm_bindgen::JsCast;
    if let Ok(fn_val) = js_sys::Reflect::get(window, &name.into()) {
        if !fn_val.is_undefined() && !fn_val.is_null() {
            let arr = js_sys::Array::new();
            for a in args {
                arr.push(a);
            }
            let _ = fn_val
                .unchecked_into::()
                .apply(window, &arr);
        }
    }
}

/// 文章内容组件。
///
/// Props:
/// - `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 {
    // 直接在 render 内拆分片段(纯函数调用,符合渲染纯净性)。
    //
    // 不用 use_memo:memo 依赖 ReactiveContext 追踪闭包内读取的 signal 才会重算,
    // 但 content_html 是普通 String prop,读取它不建立订阅——memo 会永久缓存首次
    // 解析结果。当上下篇切换、content_html prop 变化时 memo 不重算,返回旧 fragments,
    // 导致 dangerous_inner_html 收到旧 html、diff 判断属性未变 → 正文 DOM 不更新
    // (表现为标题/描述更新了但正文停在旧文章)。split_content_fragments 是纯函数,
    // 每次渲染重新解析开销可控。
    let fragments = split_content_fragments(&content_html);

    // mermaid 流程图主题需随当前生效主题(light/dark)切换。读 use_resolved_theme()
    // 建立订阅:主题变化时下方 use_effect 重跑,重调 __initMermaid 传入新 theme;
    // mermaid.ts 用 dataset.mermaidTheme 记住上次渲染主题,检测到主题变化时按缓存
    // 源码重渲染(mermaid 颜色烤进 SVG 内联样式,无法靠 CSS 原地切主题)。
    #[cfg(target_arch = "wasm32")]
    let resolved_theme = crate::theme::use_resolved_theme();

    // scrollToHash 的一次性守卫:仅首次 effect 运行时调用。
    // 下方 use_effect 因读取 resolved_theme() 建立订阅,主题切换时会重跑(为重跑
    // mermaid 主题)。但 scrollToHash 是首屏异步取数后的补救滚动,不应随主题切换
    // 再次触发——否则切换主题会把页面跳回 URL hash 位置(即使 hash 已不在用户视野)。
    #[cfg(target_arch = "wasm32")]
    let mut did_scroll = use_signal(|| false);

    #[cfg(target_arch = "wasm32")]
    use_effect(move || {
        let window = web_sys::window().unwrap();

        // 调用 window.__initPostContent('.post-content'):函数不存在时静默跳过
        // (与旧 eval 中的 if 守卫语义一致)。
        invoke_optional_global(&window, "__initPostContent", &[".post-content".into()]);

        // mermaid 流程图懒加载渲染:扫描 .post-content 下的 language-mermaid 代码块,
        // IntersectionObserver 视口可见时动态 import /mermaid/mermaid.js 渲染成 SVG。
        // 读取 resolved_theme() 既是为了传主题,也建立订阅让主题切换重跑此 effect。
        let theme_str: String = if resolved_theme() == crate::theme::ResolvedTheme::Dark {
            "dark".into()
        } else {
            "light".into()
        };
        invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]);

        // lightbox 改由 Dioxus.toml 全局