Skip to main content

fc_dioxus/components/
code.rs

1//! Source code for reading, from code.css.
2//!
3//! A [`CodeBlock`] draws spans it is given. [`CodeSource::highlighted`] makes them with
4//! upstream's `fc-highlight`, the Rust twin of `highlight.ts`, so a block tokenizes the
5//! same way on every renderer as it does in the browser; any other highlighter's spans
6//! work too. Every span sits in one `<code>`, so its text is byte-identical to the
7//! source.
8use crate::{
9    core::{
10        assets,
11        attrs::{component_attributes, lang_attribute},
12    },
13    prelude::*,
14    theme::use_stylesheets,
15};
16const BEM: Bem = Bem::new("fc-code");
17const TOK: Bem = Bem::new("fc-code__tok");
18fc_modifier! { enum Layout for BEM { Wrap => None, Numbered => "numbered", Scroll => "scroll" } }
19fc_modifier! { enum Support for BEM { Highlighted => None, Unsupported => "unsupported" } }
20
21/// A syntax role; each has a `--fc-code-<role>` color.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub enum SyntaxRole {
25    /// Plain text.
26    Fg,
27    /// A comment.
28    Comment,
29    /// A keyword.
30    Keyword,
31    /// A string literal.
32    String,
33    /// A number.
34    Number,
35    /// A function name.
36    Function,
37    /// A type name.
38    Type,
39    /// An operator.
40    Operator,
41    /// Punctuation.
42    Punctuation,
43    /// A property or field.
44    Property,
45    /// A constant such as `true`.
46    Constant,
47    /// A markup tag.
48    Tag,
49    /// A markup attribute.
50    Attribute,
51    /// A regular expression.
52    Regexp,
53    /// A heading in prose formats.
54    Heading,
55    /// A link.
56    Link,
57    /// Something invalid.
58    Invalid,
59}
60impl SyntaxRole {
61    /// Every role.
62    pub const ALL: [Self; 17] = [
63        Self::Fg,
64        Self::Comment,
65        Self::Keyword,
66        Self::String,
67        Self::Number,
68        Self::Function,
69        Self::Type,
70        Self::Operator,
71        Self::Punctuation,
72        Self::Property,
73        Self::Constant,
74        Self::Tag,
75        Self::Attribute,
76        Self::Regexp,
77        Self::Heading,
78        Self::Link,
79        Self::Invalid,
80    ];
81    const fn suffix(self) -> &'static str {
82        match self {
83            Self::Fg => "fg",
84            Self::Comment => "comment",
85            Self::Keyword => "keyword",
86            Self::String => "string",
87            Self::Number => "number",
88            Self::Function => "function",
89            Self::Type => "type",
90            Self::Operator => "operator",
91            Self::Punctuation => "punctuation",
92            Self::Property => "property",
93            Self::Constant => "constant",
94            Self::Tag => "tag",
95            Self::Attribute => "attribute",
96            Self::Regexp => "regexp",
97            Self::Heading => "heading",
98            Self::Link => "link",
99            Self::Invalid => "invalid",
100        }
101    }
102}
103
104/// A run of text sharing a role. Spans are contiguous and do not overlap.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct SyntaxSpan {
107    /// Its role.
108    pub role: SyntaxRole,
109    /// Its text.
110    pub text: String,
111}
112impl SyntaxSpan {
113    /// A span.
114    #[must_use]
115    pub fn new(role: SyntaxRole, text: impl Into<String>) -> Self {
116        Self {
117            role,
118            text: text.into(),
119        }
120    }
121}
122
123/// What a code block shows.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum CodeSource {
126    /// Unhighlighted text.
127    Plain(String),
128    /// Highlighted spans whose texts concatenate to the source.
129    Spans(Vec<SyntaxSpan>),
130}
131impl CodeSource {
132    /// `source` tokenized by upstream's highlighter, or plain text when it knows no
133    /// language by this name or alias (see [`CodeSource::supports`]).
134    #[must_use]
135    pub fn highlighted(source: &str, lang: &str) -> Self {
136        if !Self::supports(lang) {
137            return Self::Plain(source.to_owned());
138        }
139        Self::Spans(
140            fc_highlight::highlight(source, lang)
141                .into_iter()
142                .map(|span| SyntaxSpan::new(role(span.role), span.text))
143                .collect(),
144        )
145    }
146
147    /// Whether upstream's highlighter knows `lang`, as a name ("rust") or an alias
148    /// ("rs"). A block of any other language sets `unsupported`.
149    #[must_use]
150    pub fn supports(lang: &str) -> bool {
151        fc_highlight::Language::resolve(lang).is_some()
152    }
153
154    fn text(&self) -> String {
155        match self {
156            Self::Plain(text) => text.clone(),
157            Self::Spans(spans) => spans.iter().map(|s| s.text.as_str()).collect(),
158        }
159    }
160}
161
162/// The upstream role's twin. Both enums mirror `highlight.ts`'s `SyntaxRole`.
163const fn role(role: fc_highlight::Role) -> SyntaxRole {
164    use fc_highlight::Role as R;
165    match role {
166        R::Fg => SyntaxRole::Fg,
167        R::Comment => SyntaxRole::Comment,
168        R::Keyword => SyntaxRole::Keyword,
169        R::String => SyntaxRole::String,
170        R::Number => SyntaxRole::Number,
171        R::Function => SyntaxRole::Function,
172        R::Type => SyntaxRole::Type,
173        R::Operator => SyntaxRole::Operator,
174        R::Punctuation => SyntaxRole::Punctuation,
175        R::Property => SyntaxRole::Property,
176        R::Constant => SyntaxRole::Constant,
177        R::Tag => SyntaxRole::Tag,
178        R::Attribute => SyntaxRole::Attribute,
179        R::Regexp => SyntaxRole::Regexp,
180        R::Heading => SyntaxRole::Heading,
181        R::Link => SyntaxRole::Link,
182        R::Invalid => SyntaxRole::Invalid,
183    }
184}
185
186/// How a code block treats long lines.
187#[derive(Debug, Default, Clone, PartialEq, Eq)]
188pub enum CodeLayout {
189    /// Wrap long lines.
190    #[default]
191    Wrap,
192    /// Wrap, with a line-number gutter (one number per logical line).
193    Numbered,
194    /// Keep lines whole and scroll sideways, in a focusable region named by this.
195    Scroll(AccessibleName),
196}
197
198/// A read-only block of source code.
199#[component]
200pub fn CodeBlock(
201    source: CodeSource,
202    /// The language, as `data-fc-lang`, e.g. "rust".
203    #[props(default)]
204    lang: Option<String>,
205    #[props(default)] layout: CodeLayout,
206    /// No highlighter knows this language: show its name in a bar above the text.
207    #[props(default)]
208    unsupported: bool,
209    #[props(default)] class: Option<String>,
210    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
211) -> Element {
212    let shape = match layout {
213        CodeLayout::Wrap => Layout::Wrap,
214        CodeLayout::Numbered => Layout::Numbered,
215        CodeLayout::Scroll(_) => Layout::Scroll,
216    };
217    let mut attributes = component_attributes(
218        attributes,
219        ClassList::new(BEM.block())
220            .with_modifier(BEM, shape)
221            .with_modifier(
222                BEM,
223                if unsupported {
224                    Support::Unsupported
225                } else {
226                    Support::Highlighted
227                },
228            )
229            .extend(class.as_deref()),
230        &["dir", "role", "aria-label", "tabindex"],
231    );
232    if let Some(lang) = &lang {
233        attributes.push(lang_attribute(lang));
234    }
235    if let CodeLayout::Scroll(label) = &layout {
236        attributes.push(Attribute::new("role", "region", None, false));
237        attributes.push(Attribute::new("aria-label", label.to_string(), None, false));
238        attributes.push(Attribute::new("tabindex", "0", None, false));
239    }
240    let lines = source.text().split('\n').count();
241    rsx! {
242        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_CODE_CSS } }
243        // Code reads left to right in every document.
244        div { dir: "ltr", ..attributes,
245            if unsupported {
246                div { class: BEM.element("bar"), span { class: BEM.element("lang"), "{lang.clone().unwrap_or_default()}" } }
247            }
248            if layout == CodeLayout::Numbered {
249                div { class: BEM.element("gutter"), aria_hidden: "true",
250                    for number in 1..=lines { span { key: "{number}", "{number}" } }
251                }
252            }
253            code { class: BEM.element("source"), tabindex: "0",
254                match &source {
255                    CodeSource::Plain(text) => rsx! { "{text}" },
256                    CodeSource::Spans(spans) => rsx! {
257                        for (index, span) in spans.iter().enumerate() {
258                            span { key: "{index}", class: TOK.modifier_raw(span.role.suffix()), "{span.text}" }
259                        }
260                    },
261                }
262            }
263        }
264    }
265}