1mod generated;
36
37pub use generated::{names, Icon, ICONS};
38
39use std::collections::BTreeMap;
40
41pub struct IconReg {
46 pub name: String,
48 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#[derive(Debug, PartialEq, Eq)]
63pub enum IconError {
64 BadName(String),
66 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
82pub 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
94pub 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 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
158pub 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 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 pub fn add(&mut self, owner: &str, reg: &IconReg) -> Result<String, IconError> {
188 if !is_valid_name(®.name) {
189 return Err(IconError::BadName(reg.name.clone()));
190 }
191 let body = validate(®.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 pub fn has(&self, name: &str) -> bool {
200 self.entries.contains_key(name)
201 }
202
203 pub fn body(&self, name: &str) -> Option<&str> {
205 self.entries.get(name).map(|s| s.as_str())
206 }
207
208 pub fn len(&self) -> usize {
210 self.entries.len()
211 }
212
213 pub fn is_empty(&self) -> bool {
215 self.entries.is_empty()
216 }
217
218 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 hits.sort_by_key(|k| (k.len().abs_diff(name.len()), *k));
241 hits.truncate(limit);
242 hits
243 }
244
245 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 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 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 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('&', "&")
308 .replace('<', "<")
309 .replace('>', ">")
310 .replace('"', """)
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 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 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 (
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 let set = IconSet::new();
428 assert!(set.nearest("userss", 3).contains(&"users"));
430 assert!(set.nearest("chart-bars", 3).contains(&"chart-bar"));
432 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("""));
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 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 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 assert!(!set.inline("clock", None).unwrap().contains("data-mirror"));
512 }
513}