Skip to main content

dioxus_mdx/components/
code.rs

1//! Code block components for documentation.
2//!
3//! Features syntax highlighting for common programming languages.
4
5use dioxus::prelude::*;
6#[cfg(feature = "highlight")]
7use dioxus_code::{Code, CodeTheme, Language, SourceCode, Theme};
8use dioxus_free_icons::{Icon, icons::ld_icons::*};
9
10#[cfg(feature = "mermaid")]
11use super::mermaid::MermaidDiagram;
12use crate::parser::{CodeBlockNode, CodeGroupNode};
13
14/// Reactive override for the syntax-highlighting theme used by rendered code blocks.
15///
16/// Provide this context above [`MdxContent`](crate::MdxContent) (or any component that
17/// renders [`DocCodeBlock`]) to control the code theme — for example to track a
18/// site-wide light/dark toggle driven by the `data-theme` attribute.
19///
20/// When no override is provided, code blocks fall back to
21/// `CodeTheme::system(Theme::GITHUB_LIGHT, Theme::TOKYO_NIGHT)`, which switches on the
22/// reader's OS `prefers-color-scheme`.
23///
24/// Only available with the `highlight` feature (default), which pulls in `dioxus-code`.
25#[cfg(feature = "highlight")]
26#[derive(Clone, Copy)]
27pub struct CodeThemeOverride(pub ReadSignal<CodeTheme>);
28
29/// Props for DocCodeBlock component.
30#[derive(Props, Clone, PartialEq)]
31pub struct DocCodeBlockProps {
32    /// Code block data.
33    pub block: CodeBlockNode,
34}
35
36/// Single code block with syntax highlighting and copy button.
37#[component]
38pub fn DocCodeBlock(props: DocCodeBlockProps) -> Element {
39    // Mermaid blocks are rendered as diagrams, not syntax-highlighted code
40    #[cfg(feature = "mermaid")]
41    if props.block.language.as_deref() == Some("mermaid") {
42        return rsx! { MermaidDiagram { code: props.block.code.clone() } };
43    }
44
45    let copied = use_signal(|| false);
46    let code = props.block.code.clone();
47    let code_for_copy = code.clone();
48
49    rsx! {
50        // `not-prose` opts the whole block out of Tailwind Typography: a consumer's
51        // `prose-code:*` / `prose-pre:*` utilities otherwise target the inner
52        // `<pre class="dxc"><code>`, painting the inline-code pill background as a box
53        // behind every wrapped line. dioxus-code styles the block itself.
54        div { class: "dk-code-block not-prose my-6 relative group inline-block max-w-full rounded-lg border border-base-content/10 overflow-hidden",
55            // Language label and filename - refined header
56            if props.block.language.is_some() || props.block.filename.is_some() {
57                div { class: "flex items-center justify-between bg-base-200/80 px-4 py-2.5 border-b border-base-content/10 text-sm",
58                    span { class: "text-base-content/60 font-mono text-xs tracking-wide",
59                        if let Some(filename) = &props.block.filename {
60                            "{filename}"
61                        } else if let Some(lang) = &props.block.language {
62                            "{lang}"
63                        }
64                    }
65                    // Copy button - always visible with subtle opacity
66                    CopyButton {
67                        code: code_for_copy.clone(),
68                        copied: copied,
69                    }
70                }
71            }
72
73            // Code content with syntax highlighting
74            div {
75                class: if props.block.language.is_some() || props.block.filename.is_some() {
76                    "dk-code-block-body bg-base-200"
77                } else {
78                    "dk-code-block-body dk-code-block-body--bare bg-base-200 relative"
79                },
80                HighlightedCode {
81                    code: code.clone(),
82                    language: props.block.language.clone(),
83                    filename: props.block.filename.clone(),
84                }
85                // Copy button for blocks without header
86                if props.block.language.is_none() && props.block.filename.is_none() {
87                    div { class: "absolute top-3 right-3",
88                        CopyButton {
89                            code: code_for_copy,
90                            copied: copied,
91                        }
92                    }
93                }
94            }
95        }
96    }
97}
98
99/// Props for DocCodeGroup component.
100#[derive(Props, Clone, PartialEq)]
101pub struct DocCodeGroupProps {
102    /// Code group data.
103    pub group: CodeGroupNode,
104}
105
106/// Code group with multiple language variants in tabs.
107#[component]
108pub fn DocCodeGroup(props: DocCodeGroupProps) -> Element {
109    let mut active_tab = use_signal(|| 0usize);
110
111    rsx! {
112        div { class: "dk-code-block not-prose my-6 inline-block max-w-full rounded-lg border border-base-content/10 overflow-hidden",
113            // Tab headers - refined styling with subtle shadows
114            div { class: "flex items-center bg-base-200/80 border-b border-base-content/10",
115                for (i, block) in props.group.blocks.iter().enumerate() {
116                    button {
117                        key: "{i}",
118                        class: if active_tab() == i {
119                            "px-4 py-2.5 text-sm font-medium text-primary border-b-2 border-primary -mb-px bg-base-200/60 transition-colors"
120                        } else {
121                            "px-4 py-2.5 text-sm font-medium text-base-content/60 hover:text-base-content hover:bg-base-300/20 transition-colors"
122                        },
123                        onclick: move |_| active_tab.set(i),
124                        if let Some(filename) = &block.filename {
125                            "{filename}"
126                        } else if let Some(lang) = &block.language {
127                            "{lang}"
128                        } else {
129                            "Code"
130                        }
131                    }
132                }
133            }
134
135            // Active code block
136            if let Some(block) = props.group.blocks.get(active_tab()) {
137                CodeGroupBlock { block: block.clone() }
138            }
139        }
140    }
141}
142
143/// Props for CodeGroupBlock.
144#[derive(Props, Clone, PartialEq)]
145struct CodeGroupBlockProps {
146    block: CodeBlockNode,
147}
148
149/// Code block within a code group (no top border radius).
150#[component]
151fn CodeGroupBlock(props: CodeGroupBlockProps) -> Element {
152    let copied = use_signal(|| false);
153    let code = props.block.code.clone();
154
155    rsx! {
156        div { class: "dk-code-group-block bg-base-200 relative group",
157            HighlightedCode {
158                code: code.clone(),
159                language: props.block.language.clone(),
160                filename: props.block.filename.clone(),
161            }
162            div { class: "absolute top-3 right-3",
163                CopyButton {
164                    code: code.clone(),
165                    copied: copied,
166                }
167            }
168        }
169    }
170}
171
172#[derive(Props, Clone, PartialEq)]
173struct HighlightedCodeProps {
174    code: String,
175    language: Option<String>,
176    filename: Option<String>,
177}
178
179#[cfg(feature = "highlight")]
180#[component]
181fn HighlightedCode(props: HighlightedCodeProps) -> Element {
182    let language = code_language(props.language.as_deref(), props.filename.as_deref());
183    let theme = match try_use_context::<CodeThemeOverride>() {
184        Some(CodeThemeOverride(theme)) => theme(),
185        None => CodeTheme::system(Theme::GITHUB_LIGHT, Theme::TOKYO_NIGHT),
186    };
187
188    rsx! {
189        Code {
190            src: SourceCode::new(language, props.code),
191            theme,
192        }
193    }
194}
195
196/// Fallback for [`HighlightedCode`] when the `highlight` feature is disabled.
197///
198/// Renders the code as an escaped plain-text node inside the same
199/// `<pre class="dxc"><code>` markup the highlighted path emits, so the outer
200/// wrappers, copy buttons, and CodeGroup tabs keep working — only token coloring
201/// is lost. See [`plain_code_block`] for why the base layout is inlined.
202#[cfg(not(feature = "highlight"))]
203#[component]
204fn HighlightedCode(props: HighlightedCodeProps) -> Element {
205    plain_code_block(&props.code)
206}
207
208/// Render a `<pre class="dxc"><code>` block containing the code as an escaped
209/// plain-text node, used when syntax highlighting is disabled.
210///
211/// Without the `highlight` feature `dioxus-code`'s stylesheet is not linked, so its
212/// base `.dxc` layout (padding, `overflow: auto`, monospace font) is inlined here to
213/// keep scrolling and spacing intact.
214#[cfg(not(feature = "highlight"))]
215pub(crate) fn plain_code_block(code: &str) -> Element {
216    rsx! {
217        pre {
218            class: "dxc",
219            margin: "0",
220            padding: "1rem",
221            overflow: "auto",
222            tab_size: "4",
223            font_family: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace",
224            font_size: "0.9rem",
225            line_height: "1.55",
226            code { "{code}" }
227        }
228    }
229}
230
231#[cfg(feature = "highlight")]
232pub(crate) fn code_language(language: Option<&str>, filename: Option<&str>) -> Language {
233    language
234        .and_then(language_from_alias)
235        .or_else(|| filename.and_then(Language::detect))
236        .or_else(|| language.and_then(Language::detect))
237        .unwrap_or(Language::Markdown)
238}
239
240#[cfg(feature = "highlight")]
241fn language_from_alias(language: &str) -> Option<Language> {
242    let normalized = language.trim().to_ascii_lowercase();
243    match normalized.as_str() {
244        "bash" | "sh" | "shell" | "zsh" | "console" | "terminal" => Some(Language::Bash),
245        "c++" | "cc" | "cxx" | "hpp" => Some(Language::Cpp),
246        "c#" | "cs" => Some(Language::CSharp),
247        "docker" | "dockerfile" | "containerfile" => Some(Language::Dockerfile),
248        "html" | "htm" => Some(Language::Html),
249        "js" | "javascript" | "jsx" | "mjs" | "cjs" => Some(Language::JavaScript),
250        "json" | "jsonc" => Some(Language::Json),
251        "markdown" | "md" | "mdx" => Some(Language::Markdown),
252        "py" | "python" => Some(Language::Python),
253        "rs" | "rust" => Some(Language::Rust),
254        "ts" | "typescript" => Some(Language::TypeScript),
255        "tsx" => Some(Language::Tsx),
256        "toml" => Some(Language::Toml),
257        "yaml" | "yml" => Some(Language::Yaml),
258        other => Language::from_slug(other),
259    }
260}
261
262/// Props for CopyButton.
263#[derive(Props, Clone, PartialEq)]
264struct CopyButtonProps {
265    code: String,
266    copied: Signal<bool>,
267}
268
269/// Copy to clipboard button.
270#[component]
271fn CopyButton(props: CopyButtonProps) -> Element {
272    #[allow(unused_mut)]
273    let mut copied = props.copied;
274    let code = props.code.clone();
275
276    rsx! {
277        button {
278            class: "btn btn-ghost btn-xs opacity-60 hover:opacity-100 group-hover:opacity-100 transition-all duration-150 hover:bg-base-content/10",
279            "data-code": "{code}",
280            onclick: move |_| {
281                // Use JavaScript for clipboard (client-side only)
282                #[cfg(target_arch = "wasm32")]
283                {
284                    use dioxus::prelude::*;
285                    let code = code.clone();
286                    spawn(async move {
287                        // Use eval to copy to clipboard
288                        let js = format!(
289                            "navigator.clipboard.writeText({}).catch(console.error)",
290                            serde_json::to_string(&code).unwrap_or_default()
291                        );
292                        let _ = document::eval(&js);
293                        copied.set(true);
294                        gloo_timers::future::TimeoutFuture::new(2000).await;
295                        copied.set(false);
296                    });
297                }
298            },
299            if copied() {
300                Icon { class: "size-4 text-success", icon: LdCheck }
301            } else {
302                Icon { class: "size-4", icon: LdCopy }
303            }
304        }
305    }
306}