Skip to main content

laterite_core/icons/
mod.rs

1//! Icons: a curated set, a registry plugins extend, and two ways to render one.
2//!
3//! The admin panel needs icons and so does any public site an application
4//! builds, so the set lives here and the admin is one consumer of it. What is
5//! offered is a mechanism: glyph data and markup. Class names, sizes and colours
6//! belong to whoever renders.
7//!
8//! # Naming
9//!
10//! A framework icon is named plainly: `users`, `bot`, `newspaper`. There is no
11//! prefix, because a name here is the value of an `icon` field resolved through
12//! this registry, not a CSS class sharing one global namespace with everything
13//! on the page. Prefixes exist in class-based systems to prevent a collision
14//! that cannot happen to us.
15//!
16//! A plugin's own icon is namespaced by its module, `rainmill.location:pin`,
17//! stamped by the registry rather than typed, so two plugins cannot collide and
18//! neither can claim a bare name.
19//!
20//! # Variants
21//!
22//! There are none in the name. Filling a shape shows **state** (a filled star is
23//! favourited), so it is a render option that the code displaying the state
24//! passes, never something the author of a navigation entry types. Icons where
25//! filling reads correctly are marked [`Icon::fillable`].
26//!
27//! # Curation
28//!
29//! Upstream ships over two thousand glyphs. The reference CMS bundles about that
30//! many across three icon fonts and its own modules use 47 of them. Shipping the
31//! long tail costs every consumer to serve almost nobody, so this set is
32//! curated and a plugin registers whatever else it needs. The admission rules,
33//! and the evidence for each group, are in `icons.toml`.
34
35mod generated;
36
37pub use generated::{names, Icon, ICONS};
38
39use std::collections::BTreeMap;
40
41/// A plugin's own icon, registered through the module registry.
42///
43/// The SVG must be a `24x24` root element drawing with `currentColor`; see
44/// [`validate`] for what is refused and why.
45pub struct IconReg {
46    /// The name within the contributing module, without its namespace.
47    pub name: String,
48    /// The full `<svg>` element, usually `include_str!` of a file.
49    pub svg: String,
50}
51
52impl IconReg {
53    pub fn new(name: impl Into<String>, svg: impl Into<String>) -> Self {
54        Self {
55            name: name.into(),
56            svg: svg.into(),
57        }
58    }
59}
60
61/// Why a contributed icon was refused.
62#[derive(Debug, PartialEq, Eq)]
63pub enum IconError {
64    /// The name is not `lowercase-with-hyphens`.
65    BadName(String),
66    /// The markup would break the page or the sprite. Carries what and why.
67    BadSvg(String),
68}
69
70impl std::fmt::Display for IconError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            IconError::BadName(n) => write!(
74                f,
75                "`{n}` is not a valid icon name: use lowercase letters, digits and hyphens"
76            ),
77            IconError::BadSvg(why) => write!(f, "{why}"),
78        }
79    }
80}
81
82/// Whether a name is well formed: `lowercase-with-hyphens`, no leading, trailing
83/// or doubled separator.
84pub fn is_valid_name(name: &str) -> bool {
85    !name.is_empty()
86        && name.split('-').all(|part| {
87            !part.is_empty()
88                && part
89                    .chars()
90                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
91        })
92}
93
94/// Checks a contributed SVG, returning the drawing instructions on success.
95///
96/// Each refusal prevents a specific failure rather than enforcing taste:
97///
98/// - **A script, style or foreign object** would execute or leak styles into
99///   every page that renders the icon.
100/// - **An `id`** would collide once several icons share one sprite.
101/// - **A literal colour** ignores the surrounding text colour, so the icon stays
102///   light on a dark background.
103/// - **An external reference** would fetch from somewhere we do not control.
104pub fn validate(svg: &str) -> Result<String, IconError> {
105    let refuse = |why: &str| Err(IconError::BadSvg(why.to_string()));
106
107    let Some(open) = svg.find("<svg") else {
108        return refuse("not an SVG: no root <svg> element");
109    };
110    let Some(head_end) = svg[open..].find('>').map(|i| open + i) else {
111        return refuse("malformed SVG: the root element is not closed");
112    };
113    let head = &svg[open..head_end];
114    if !head.contains("viewBox=\"0 0 24 24\"") {
115        return refuse("icons must use viewBox=\"0 0 24 24\" so they align with the set");
116    }
117
118    let lower = svg.to_ascii_lowercase();
119    for (needle, why) in [
120        ("<script", "an icon may not contain a script"),
121        (
122            "<style",
123            "an icon may not contain a style element; it would leak into the page",
124        ),
125        ("<foreignobject", "an icon may not contain a foreignObject"),
126        (
127            "xlink:href",
128            "an icon may not reference anything outside itself",
129        ),
130        (
131            " id=",
132            "an icon may not carry an id; ids collide once icons share a sprite",
133        ),
134    ] {
135        if lower.contains(needle) {
136            return refuse(why);
137        }
138    }
139
140    let Some(close) = svg.rfind("</svg>") else {
141        return refuse("malformed SVG: no closing </svg>");
142    };
143    let body = svg[head_end + 1..close].trim().to_string();
144
145    // A literal colour in the body ignores the text colour around it, so the
146    // icon would stay dark on a dark background.
147    for attr in ["fill=\"#", "stroke=\"#", "fill=\"rgb", "stroke=\"rgb"] {
148        if body.to_ascii_lowercase().contains(attr) {
149            return refuse(
150                "an icon may only use currentColor or none; a literal colour breaks dark mode",
151            );
152        }
153    }
154
155    Ok(body)
156}
157
158/// The icons available to a running application: the framework's set plus what
159/// modules contributed.
160pub struct IconSet {
161    entries: BTreeMap<String, String>,
162    flags: BTreeMap<String, (bool, bool)>,
163}
164
165impl Default for IconSet {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl IconSet {
172    /// The framework's own set.
173    pub fn new() -> Self {
174        let mut entries = BTreeMap::new();
175        let mut flags = BTreeMap::new();
176        for icon in ICONS.iter() {
177            entries.insert(icon.name.to_string(), icon.body.to_string());
178            flags.insert(icon.name.to_string(), (icon.fillable, icon.mirror));
179        }
180        Self { entries, flags }
181    }
182
183    /// Adds a module's own icon, namespaced to it.
184    ///
185    /// The key becomes `{owner}:{name}`, so a bare name stays reserved to the
186    /// framework and two modules cannot collide however they name things.
187    pub fn add(&mut self, owner: &str, reg: &IconReg) -> Result<String, IconError> {
188        if !is_valid_name(&reg.name) {
189            return Err(IconError::BadName(reg.name.clone()));
190        }
191        let body = validate(&reg.svg)?;
192        let key = format!("{owner}:{}", reg.name);
193        self.entries.insert(key.clone(), body);
194        self.flags.insert(key.clone(), (false, false));
195        Ok(key)
196    }
197
198    /// Whether a name resolves.
199    pub fn has(&self, name: &str) -> bool {
200        self.entries.contains_key(name)
201    }
202
203    /// The drawing instructions for a name.
204    pub fn body(&self, name: &str) -> Option<&str> {
205        self.entries.get(name).map(|s| s.as_str())
206    }
207
208    /// How many icons are available.
209    pub fn len(&self) -> usize {
210        self.entries.len()
211    }
212
213    /// Whether the set is empty, which it never is in practice.
214    pub fn is_empty(&self) -> bool {
215        self.entries.is_empty()
216    }
217
218    /// Names closest to `name`, for telling an author what they probably meant.
219    ///
220    /// Substring matches first, then names sharing a leading word, which between
221    /// them catch the mistakes people actually make: a near-miss (`user` for
222    /// `users`) and a wrong suffix (`chart-bars` for `chart-bar`).
223    pub fn nearest(&self, name: &str, limit: usize) -> Vec<&str> {
224        let mut hits: Vec<&str> = self
225            .entries
226            .keys()
227            .filter(|k| k.contains(name) || name.contains(k.as_str()))
228            .map(|k| k.as_str())
229            .collect();
230        if let Some(head) = name.split('-').next() {
231            for key in self.entries.keys() {
232                if key.starts_with(head) && !hits.contains(&key.as_str()) {
233                    hits.push(key);
234                }
235            }
236        }
237        // Closest length first. Alphabetical order would put `user-plus` and
238        // `user-round` ahead of `users` for the typo `usres`, which is the one
239        // suggestion that would actually have helped.
240        hits.sort_by_key(|k| (k.len().abs_diff(name.len()), *k));
241        hits.truncate(limit);
242        hits
243    }
244
245    /// Every icon as one SVG sprite, for serving at a single cached URL.
246    ///
247    /// A symbol carries `viewBox` and nothing else. A presentation attribute
248    /// here would beat whatever the use site inherits and could not be reached
249    /// by page CSS, so stroke, fill and caps stay on the outer element where a
250    /// consumer can restyle them.
251    pub fn sprite(&self) -> String {
252        let mut out =
253            String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" style="display:none">"#);
254        for (name, body) in &self.entries {
255            // `:` is legal in an id but awkward in a URL fragment, so a
256            // namespaced plugin icon is flattened.
257            let id = name.replace(':', "--");
258            out.push_str(&format!(
259                r#"<symbol id="lat-{id}" viewBox="0 0 24 24">{body}</symbol>"#
260            ));
261        }
262        out.push_str("</svg>");
263        out
264    }
265
266    /// A reference into the sprite: two nodes and about seventy bytes, against
267    /// the few hundred an inlined glyph costs every time it renders.
268    ///
269    /// Decorative by default, for the same reason as [`IconSet::inline`].
270    pub fn use_ref(&self, name: &str, sprite_url: &str, label: Option<&str>) -> Option<String> {
271        if !self.has(name) {
272            return None;
273        }
274        let (_, mirror) = self.flags.get(name).copied().unwrap_or((false, false));
275        let semantics = match label {
276            Some(text) => format!(r#" role="img" aria-label="{}""#, escape(text)),
277            None => r#" aria-hidden="true" focusable="false""#.to_string(),
278        };
279        let data = if mirror { r#" data-mirror="true""# } else { "" };
280        let id = name.replace(':', "--");
281        Some(format!(
282            r##"<svg class="lat-icon"{semantics}{data}><use href="{sprite_url}#lat-{id}"/></svg>"##
283        ))
284    }
285
286    /// Inline SVG for a name, for a context that cannot fetch a sprite.
287    ///
288    /// Decorative by default: a screen reader skips it, because an icon beside a
289    /// label repeats the label. Pass a label only when the icon is the whole
290    /// meaning, and prefer naming the button around it.
291    pub fn inline(&self, name: &str, label: Option<&str>) -> Option<String> {
292        let body = self.body(name)?;
293        let (fillable, mirror) = self.flags.get(name).copied().unwrap_or((false, false));
294        let semantics = match label {
295            Some(text) => format!(r#" role="img" aria-label="{}""#, escape(text)),
296            None => r#" aria-hidden="true" focusable="false""#.to_string(),
297        };
298        let data = if mirror { r#" data-mirror="true""# } else { "" };
299        let _ = fillable;
300        Some(format!(
301            r#"<svg class="lat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"{semantics}{data}>{body}</svg>"#
302        ))
303    }
304}
305
306fn escape(text: &str) -> String {
307    text.replace('&', "&amp;")
308        .replace('<', "&lt;")
309        .replace('>', "&gt;")
310        .replace('"', "&quot;")
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn the_set_covers_what_the_framework_and_its_plugins_name() {
319        // The bug this replaces: `bot`, `map` and `sparkles` were named by a
320        // first-party plugin, were not in the set of eight, and all three
321        // silently rendered the same fallback glyph.
322        let set = IconSet::new();
323        for name in [
324            "bot",
325            "map",
326            "sparkles",
327            "users",
328            "shield",
329            "settings",
330            "plug",
331            "history",
332            "external-link",
333            "layout-dashboard",
334        ] {
335            assert!(set.has(name), "`{name}` must be in the set");
336        }
337    }
338
339    #[test]
340    fn a_plugin_icon_is_namespaced_to_its_module() {
341        let mut set = IconSet::new();
342        let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
343        let key = set
344            .add("rainmill.location", &IconReg::new("pin-house", svg))
345            .unwrap();
346        assert_eq!(key, "rainmill.location:pin-house");
347        // A bare name stays the framework's; a plugin cannot claim one.
348        assert!(!set.has("pin-house"));
349        assert!(set.has("rainmill.location:pin-house"));
350    }
351
352    #[test]
353    fn two_plugins_cannot_collide() {
354        let mut set = IconSet::new();
355        let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
356        let a = set.add("acme.one", &IconReg::new("widget", svg)).unwrap();
357        let b = set.add("acme.two", &IconReg::new("widget", svg)).unwrap();
358        assert_ne!(a, b);
359        assert_eq!(set.len(), ICONS.len() + 2);
360    }
361
362    #[test]
363    fn dangerous_or_unusable_markup_is_refused() {
364        let cases = [
365            (
366                r#"<svg viewBox="0 0 24 24"><script>x()</script></svg>"#,
367                "script",
368            ),
369            (
370                r#"<svg viewBox="0 0 24 24"><style>a{}</style></svg>"#,
371                "style",
372            ),
373            (
374                r#"<svg viewBox="0 0 24 24"><path id="a" d="M1 1"/></svg>"#,
375                "id",
376            ),
377            // A deeper delimiter: the markup itself contains `"#`.
378            (
379                r##"<svg viewBox="0 0 24 24"><path fill="#f00" d="M1 1"/></svg>"##,
380                "colour",
381            ),
382            (
383                r#"<svg viewBox="0 0 48 48"><path d="M1 1"/></svg>"#,
384                "viewBox",
385            ),
386            ("not an svg at all", "root"),
387        ];
388        for (svg, what) in cases {
389            assert!(
390                validate(svg).is_err(),
391                "should have refused the {what} case: {svg}"
392            );
393        }
394    }
395
396    #[test]
397    fn a_valid_icon_yields_its_drawing_instructions() {
398        let body = validate(r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#).unwrap();
399        assert_eq!(body, r#"<path d="M1 1"/>"#);
400    }
401
402    #[test]
403    fn a_bad_name_is_refused_before_the_markup_is_read() {
404        let mut set = IconSet::new();
405        let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
406        for bad in [
407            "Users",
408            "my_icon",
409            "trailing-",
410            "-leading",
411            "double--hyphen",
412            "",
413        ] {
414            assert!(
415                matches!(
416                    set.add("acme.test", &IconReg::new(bad, svg)),
417                    Err(IconError::BadName(_))
418                ),
419                "`{bad}` should be refused"
420            );
421        }
422    }
423
424    #[test]
425    fn a_near_miss_gets_a_suggestion() {
426        // What makes the boot failure useful rather than merely loud.
427        let set = IconSet::new();
428        // A plural slip, the commonest kind.
429        assert!(set.nearest("userss", 3).contains(&"users"));
430        // A wrong suffix on a real stem.
431        assert!(set.nearest("chart-bars", 3).contains(&"chart-bar"));
432        // And a name nothing resembles suggests nothing rather than noise.
433        assert!(set.nearest("zzzzzz", 3).is_empty());
434    }
435
436    #[test]
437    fn an_icon_is_decorative_unless_it_carries_the_meaning() {
438        let set = IconSet::new();
439        let plain = set.inline("users", None).unwrap();
440        assert!(plain.contains(r#"aria-hidden="true""#));
441        assert!(!plain.contains("aria-label"));
442
443        let labelled = set.inline("users", Some("Team")).unwrap();
444        assert!(labelled.contains(r#"role="img""#));
445        assert!(labelled.contains(r#"aria-label="Team""#));
446    }
447
448    #[test]
449    fn a_label_cannot_break_out_of_the_attribute() {
450        let set = IconSet::new();
451        let markup = set.inline("users", Some(r#"a" onload="x"#)).unwrap();
452        assert!(!markup.contains(r#"onload="x"#));
453        assert!(markup.contains("&quot;"));
454    }
455
456    #[test]
457    fn the_sprite_holds_every_icon_and_no_presentation_attributes() {
458        let set = IconSet::new();
459        let sprite = set.sprite();
460        assert_eq!(sprite.matches("<symbol").count(), ICONS.len());
461        assert!(sprite.contains(r#"id="lat-users""#));
462        // Stroke and fill belong to the use site: an attribute here could not be
463        // overridden by page CSS.
464        assert!(!sprite.contains("<symbol id=\"lat-users\" viewBox=\"0 0 24 24\" stroke"));
465        assert!(!sprite.contains("stroke-width=\"2\"><symbol"));
466    }
467
468    #[test]
469    fn a_reference_is_a_fraction_of_the_inlined_markup() {
470        let set = IconSet::new();
471        let inline = set.inline("users", None).unwrap();
472        let reference = set
473            .use_ref("users", "/admin/assets/icons.abc.svg", None)
474            .unwrap();
475        assert!(
476            reference.len() * 3 < inline.len(),
477            "a reference ({} bytes) should be far smaller than inlining ({} bytes)",
478            reference.len(),
479            inline.len()
480        );
481        assert!(reference.contains("#lat-users"));
482    }
483
484    #[test]
485    fn a_namespaced_icon_gets_a_url_safe_id() {
486        let mut set = IconSet::new();
487        let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
488        set.add("rainmill.location", &IconReg::new("pin", svg))
489            .unwrap();
490        // A colon in a URL fragment is legal but awkward; flattened instead.
491        assert!(set.sprite().contains(r#"id="lat-rainmill.location--pin""#));
492        let r = set
493            .use_ref("rainmill.location:pin", "/s.svg", None)
494            .unwrap();
495        assert!(r.contains("#lat-rainmill.location--pin"));
496    }
497
498    #[test]
499    fn an_unknown_name_yields_no_reference() {
500        assert!(IconSet::new().use_ref("nope", "/s.svg", None).is_none());
501    }
502
503    #[test]
504    fn direction_carrying_icons_are_marked_for_rtl() {
505        let set = IconSet::new();
506        assert!(set
507            .inline("chevron-right", None)
508            .unwrap()
509            .contains("data-mirror"));
510        // A clock does not mirror: clockwise is clockwise in every script.
511        assert!(!set.inline("clock", None).unwrap().contains("data-mirror"));
512    }
513}