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    // No grammar for this fence in this build - either the language is unknown
183    // or its `lang-*` feature is off. Render plain text rather than coloring
184    // the block with some unrelated grammar.
185    let Some(language) = code_language(props.language.as_deref(), props.filename.as_deref()) else {
186        return plain_code_block(&props.code);
187    };
188    let theme = match try_use_context::<CodeThemeOverride>() {
189        Some(CodeThemeOverride(theme)) => theme(),
190        None => CodeTheme::system(Theme::GITHUB_LIGHT, Theme::TOKYO_NIGHT),
191    };
192
193    rsx! {
194        Code {
195            src: SourceCode::new(language, props.code),
196            theme,
197        }
198    }
199}
200
201/// Fallback for [`HighlightedCode`] when the `highlight` feature is disabled.
202///
203/// Renders the code as an escaped plain-text node inside the same
204/// `<pre class="dxc"><code>` markup the highlighted path emits, so the outer
205/// wrappers, copy buttons, and CodeGroup tabs keep working — only token coloring
206/// is lost. See [`plain_code_block`] for why the base layout is inlined.
207#[cfg(not(feature = "highlight"))]
208#[component]
209fn HighlightedCode(props: HighlightedCodeProps) -> Element {
210    plain_code_block(&props.code)
211}
212
213/// Render a `<pre class="dxc"><code>` block containing the code as an escaped
214/// plain-text node, used whenever no grammar is available: the `highlight`
215/// feature is off, or the fence's `lang-*` feature is not enabled.
216///
217/// Without the `highlight` feature `dioxus-code`'s stylesheet is not linked, so its
218/// base `.dxc` layout (padding, `overflow: auto`, monospace font) is inlined here to
219/// keep scrolling and spacing intact.
220pub(crate) fn plain_code_block(code: &str) -> Element {
221    rsx! {
222        pre {
223            class: "dxc",
224            margin: "0",
225            padding: "1rem",
226            overflow: "auto",
227            tab_size: "4",
228            font_family: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace",
229            font_size: "0.9rem",
230            line_height: "1.55",
231            code { "{code}" }
232        }
233    }
234}
235
236/// Resolve a fence's language to a grammar compiled into this build.
237///
238/// Returns `None` when the language is unknown *or* when its `lang-*` feature is
239/// disabled — [`Language`]'s variants are gated by the same features, so an
240/// alias for a grammar that was not compiled in simply falls through to
241/// [`Language::from_slug`], which also returns `None` for it.
242#[cfg(feature = "highlight")]
243pub(crate) fn code_language(language: Option<&str>, filename: Option<&str>) -> Option<Language> {
244    language
245        .and_then(language_from_alias)
246        .or_else(|| filename.and_then(Language::detect))
247        .or_else(|| language.and_then(Language::detect))
248}
249
250#[cfg(feature = "highlight")]
251fn language_from_alias(language: &str) -> Option<Language> {
252    let normalized = language.trim().to_ascii_lowercase();
253    match normalized.as_str() {
254        #[cfg(feature = "lang-bash")]
255        "bash" | "sh" | "shell" | "zsh" | "console" | "terminal" => Some(Language::Bash),
256        #[cfg(feature = "lang-cpp")]
257        "c++" | "cc" | "cxx" | "hpp" => Some(Language::Cpp),
258        #[cfg(feature = "lang-c-sharp")]
259        "c#" | "cs" => Some(Language::CSharp),
260        #[cfg(feature = "lang-dockerfile")]
261        "docker" | "dockerfile" | "containerfile" => Some(Language::Dockerfile),
262        #[cfg(feature = "lang-html")]
263        "html" | "htm" => Some(Language::Html),
264        #[cfg(feature = "lang-javascript")]
265        "js" | "javascript" | "jsx" | "mjs" | "cjs" => Some(Language::JavaScript),
266        #[cfg(feature = "lang-json")]
267        "json" | "jsonc" => Some(Language::Json),
268        #[cfg(feature = "lang-markdown")]
269        "markdown" | "md" | "mdx" => Some(Language::Markdown),
270        #[cfg(feature = "lang-python")]
271        "py" | "python" => Some(Language::Python),
272        // Rust needs no `lang-*` feature: `dioxus-code`'s `runtime` always
273        // compiles it, so `Language::Rust` is never gated out.
274        "rs" | "rust" => Some(Language::Rust),
275        #[cfg(feature = "lang-typescript")]
276        "ts" | "typescript" => Some(Language::TypeScript),
277        #[cfg(feature = "lang-tsx")]
278        "tsx" => Some(Language::Tsx),
279        #[cfg(feature = "lang-toml")]
280        "toml" => Some(Language::Toml),
281        #[cfg(feature = "lang-yaml")]
282        "yaml" | "yml" => Some(Language::Yaml),
283        other => Language::from_slug(other),
284    }
285}
286
287/// Props for CopyButton.
288#[derive(Props, Clone, PartialEq)]
289struct CopyButtonProps {
290    code: String,
291    copied: Signal<bool>,
292}
293
294/// Copy to clipboard button.
295#[component]
296fn CopyButton(props: CopyButtonProps) -> Element {
297    #[allow(unused_mut)]
298    let mut copied = props.copied;
299    let code = props.code.clone();
300
301    rsx! {
302        button {
303            class: "btn btn-ghost btn-xs opacity-60 hover:opacity-100 group-hover:opacity-100 transition-all duration-150 hover:bg-base-content/10",
304            "data-code": "{code}",
305            onclick: move |_| {
306                // Use JavaScript for clipboard (client-side only)
307                #[cfg(target_arch = "wasm32")]
308                {
309                    use dioxus::prelude::*;
310                    let code = code.clone();
311                    spawn(async move {
312                        // Use eval to copy to clipboard
313                        let js = format!(
314                            "navigator.clipboard.writeText({}).catch(console.error)",
315                            serde_json::to_string(&code).unwrap_or_default()
316                        );
317                        let _ = document::eval(&js);
318                        copied.set(true);
319                        gloo_timers::future::TimeoutFuture::new(2000).await;
320                        copied.set(false);
321                    });
322                }
323            },
324            if copied() {
325                Icon { class: "size-4 text-success", icon: LdCheck }
326            } else {
327                Icon { class: "size-4", icon: LdCopy }
328            }
329        }
330    }
331}
332
333#[cfg(all(test, feature = "highlight"))]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn rust_needs_no_lang_feature() {
339        assert_eq!(code_language(Some("rs"), None), Some(Language::Rust));
340    }
341
342    #[test]
343    fn unknown_language_has_no_grammar() {
344        assert_eq!(code_language(Some("brainfuck"), None), None);
345    }
346
347    /// A fence with no language and no filename has nothing to detect from, so
348    /// `HighlightedCode` renders it as plain text instead of guessing a grammar.
349    #[test]
350    fn bare_fence_has_no_grammar() {
351        assert_eq!(code_language(None, None), None);
352    }
353
354    /// Languages whose `lang-*` feature is off resolve to `None`, which routes
355    /// the block through `plain_code_block` rather than panicking or coloring
356    /// it with an unrelated grammar. C++ and C# are excluded from the default
357    /// features because their grammars dominate the wasm bundle.
358    #[test]
359    #[cfg(not(feature = "lang-cpp"))]
360    fn disabled_grammar_falls_back_to_plain_text() {
361        assert_eq!(code_language(Some("c++"), None), None);
362        assert_eq!(code_language(Some("cpp"), None), None);
363    }
364
365    #[test]
366    #[cfg(not(feature = "lang-c-sharp"))]
367    fn disabled_c_sharp_grammar_falls_back_to_plain_text() {
368        assert_eq!(code_language(Some("c#"), None), None);
369        assert_eq!(code_language(Some("cs"), None), None);
370    }
371
372    #[test]
373    #[cfg(feature = "lang-python")]
374    fn enabled_grammar_resolves() {
375        assert_eq!(code_language(Some("py"), None), Some(Language::Python));
376    }
377}