mod generated;
pub use generated::{names, Icon, ICONS};
use std::collections::BTreeMap;
pub struct IconReg {
pub name: String,
pub svg: String,
}
impl IconReg {
pub fn new(name: impl Into<String>, svg: impl Into<String>) -> Self {
Self {
name: name.into(),
svg: svg.into(),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum IconError {
BadName(String),
BadSvg(String),
}
impl std::fmt::Display for IconError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IconError::BadName(n) => write!(
f,
"`{n}` is not a valid icon name: use lowercase letters, digits and hyphens"
),
IconError::BadSvg(why) => write!(f, "{why}"),
}
}
}
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name.split('-').all(|part| {
!part.is_empty()
&& part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
})
}
pub fn validate(svg: &str) -> Result<String, IconError> {
let refuse = |why: &str| Err(IconError::BadSvg(why.to_string()));
let Some(open) = svg.find("<svg") else {
return refuse("not an SVG: no root <svg> element");
};
let Some(head_end) = svg[open..].find('>').map(|i| open + i) else {
return refuse("malformed SVG: the root element is not closed");
};
let head = &svg[open..head_end];
if !head.contains("viewBox=\"0 0 24 24\"") {
return refuse("icons must use viewBox=\"0 0 24 24\" so they align with the set");
}
let lower = svg.to_ascii_lowercase();
for (needle, why) in [
("<script", "an icon may not contain a script"),
(
"<style",
"an icon may not contain a style element; it would leak into the page",
),
("<foreignobject", "an icon may not contain a foreignObject"),
(
"xlink:href",
"an icon may not reference anything outside itself",
),
(
" id=",
"an icon may not carry an id; ids collide once icons share a sprite",
),
] {
if lower.contains(needle) {
return refuse(why);
}
}
let Some(close) = svg.rfind("</svg>") else {
return refuse("malformed SVG: no closing </svg>");
};
let body = svg[head_end + 1..close].trim().to_string();
for attr in ["fill=\"#", "stroke=\"#", "fill=\"rgb", "stroke=\"rgb"] {
if body.to_ascii_lowercase().contains(attr) {
return refuse(
"an icon may only use currentColor or none; a literal colour breaks dark mode",
);
}
}
Ok(body)
}
pub struct IconSet {
entries: BTreeMap<String, String>,
flags: BTreeMap<String, (bool, bool)>,
}
impl Default for IconSet {
fn default() -> Self {
Self::new()
}
}
impl IconSet {
pub fn new() -> Self {
let mut entries = BTreeMap::new();
let mut flags = BTreeMap::new();
for icon in ICONS.iter() {
entries.insert(icon.name.to_string(), icon.body.to_string());
flags.insert(icon.name.to_string(), (icon.fillable, icon.mirror));
}
Self { entries, flags }
}
pub fn add(&mut self, owner: &str, reg: &IconReg) -> Result<String, IconError> {
if !is_valid_name(®.name) {
return Err(IconError::BadName(reg.name.clone()));
}
let body = validate(®.svg)?;
let key = format!("{owner}:{}", reg.name);
self.entries.insert(key.clone(), body);
self.flags.insert(key.clone(), (false, false));
Ok(key)
}
pub fn has(&self, name: &str) -> bool {
self.entries.contains_key(name)
}
pub fn body(&self, name: &str) -> Option<&str> {
self.entries.get(name).map(|s| s.as_str())
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn nearest(&self, name: &str, limit: usize) -> Vec<&str> {
let mut hits: Vec<&str> = self
.entries
.keys()
.filter(|k| k.contains(name) || name.contains(k.as_str()))
.map(|k| k.as_str())
.collect();
if let Some(head) = name.split('-').next() {
for key in self.entries.keys() {
if key.starts_with(head) && !hits.contains(&key.as_str()) {
hits.push(key);
}
}
}
hits.sort_by_key(|k| (k.len().abs_diff(name.len()), *k));
hits.truncate(limit);
hits
}
pub fn sprite(&self) -> String {
let mut out =
String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" style="display:none">"#);
for (name, body) in &self.entries {
let id = name.replace(':', "--");
out.push_str(&format!(
r#"<symbol id="lat-{id}" viewBox="0 0 24 24">{body}</symbol>"#
));
}
out.push_str("</svg>");
out
}
pub fn use_ref(&self, name: &str, sprite_url: &str, label: Option<&str>) -> Option<String> {
if !self.has(name) {
return None;
}
let (_, mirror) = self.flags.get(name).copied().unwrap_or((false, false));
let semantics = match label {
Some(text) => format!(r#" role="img" aria-label="{}""#, escape(text)),
None => r#" aria-hidden="true" focusable="false""#.to_string(),
};
let data = if mirror { r#" data-mirror="true""# } else { "" };
let id = name.replace(':', "--");
Some(format!(
r##"<svg class="lat-icon"{semantics}{data}><use href="{sprite_url}#lat-{id}"/></svg>"##
))
}
pub fn inline(&self, name: &str, label: Option<&str>) -> Option<String> {
let body = self.body(name)?;
let (fillable, mirror) = self.flags.get(name).copied().unwrap_or((false, false));
let semantics = match label {
Some(text) => format!(r#" role="img" aria-label="{}""#, escape(text)),
None => r#" aria-hidden="true" focusable="false""#.to_string(),
};
let data = if mirror { r#" data-mirror="true""# } else { "" };
let _ = fillable;
Some(format!(
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>"#
))
}
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_set_covers_what_the_framework_and_its_plugins_name() {
let set = IconSet::new();
for name in [
"bot",
"map",
"sparkles",
"users",
"shield",
"settings",
"plug",
"history",
"external-link",
"layout-dashboard",
] {
assert!(set.has(name), "`{name}` must be in the set");
}
}
#[test]
fn a_plugin_icon_is_namespaced_to_its_module() {
let mut set = IconSet::new();
let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
let key = set
.add("rainmill.location", &IconReg::new("pin-house", svg))
.unwrap();
assert_eq!(key, "rainmill.location:pin-house");
assert!(!set.has("pin-house"));
assert!(set.has("rainmill.location:pin-house"));
}
#[test]
fn two_plugins_cannot_collide() {
let mut set = IconSet::new();
let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
let a = set.add("acme.one", &IconReg::new("widget", svg)).unwrap();
let b = set.add("acme.two", &IconReg::new("widget", svg)).unwrap();
assert_ne!(a, b);
assert_eq!(set.len(), ICONS.len() + 2);
}
#[test]
fn dangerous_or_unusable_markup_is_refused() {
let cases = [
(
r#"<svg viewBox="0 0 24 24"><script>x()</script></svg>"#,
"script",
),
(
r#"<svg viewBox="0 0 24 24"><style>a{}</style></svg>"#,
"style",
),
(
r#"<svg viewBox="0 0 24 24"><path id="a" d="M1 1"/></svg>"#,
"id",
),
(
r##"<svg viewBox="0 0 24 24"><path fill="#f00" d="M1 1"/></svg>"##,
"colour",
),
(
r#"<svg viewBox="0 0 48 48"><path d="M1 1"/></svg>"#,
"viewBox",
),
("not an svg at all", "root"),
];
for (svg, what) in cases {
assert!(
validate(svg).is_err(),
"should have refused the {what} case: {svg}"
);
}
}
#[test]
fn a_valid_icon_yields_its_drawing_instructions() {
let body = validate(r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#).unwrap();
assert_eq!(body, r#"<path d="M1 1"/>"#);
}
#[test]
fn a_bad_name_is_refused_before_the_markup_is_read() {
let mut set = IconSet::new();
let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
for bad in [
"Users",
"my_icon",
"trailing-",
"-leading",
"double--hyphen",
"",
] {
assert!(
matches!(
set.add("acme.test", &IconReg::new(bad, svg)),
Err(IconError::BadName(_))
),
"`{bad}` should be refused"
);
}
}
#[test]
fn a_near_miss_gets_a_suggestion() {
let set = IconSet::new();
assert!(set.nearest("userss", 3).contains(&"users"));
assert!(set.nearest("chart-bars", 3).contains(&"chart-bar"));
assert!(set.nearest("zzzzzz", 3).is_empty());
}
#[test]
fn an_icon_is_decorative_unless_it_carries_the_meaning() {
let set = IconSet::new();
let plain = set.inline("users", None).unwrap();
assert!(plain.contains(r#"aria-hidden="true""#));
assert!(!plain.contains("aria-label"));
let labelled = set.inline("users", Some("Team")).unwrap();
assert!(labelled.contains(r#"role="img""#));
assert!(labelled.contains(r#"aria-label="Team""#));
}
#[test]
fn a_label_cannot_break_out_of_the_attribute() {
let set = IconSet::new();
let markup = set.inline("users", Some(r#"a" onload="x"#)).unwrap();
assert!(!markup.contains(r#"onload="x"#));
assert!(markup.contains("""));
}
#[test]
fn the_sprite_holds_every_icon_and_no_presentation_attributes() {
let set = IconSet::new();
let sprite = set.sprite();
assert_eq!(sprite.matches("<symbol").count(), ICONS.len());
assert!(sprite.contains(r#"id="lat-users""#));
assert!(!sprite.contains("<symbol id=\"lat-users\" viewBox=\"0 0 24 24\" stroke"));
assert!(!sprite.contains("stroke-width=\"2\"><symbol"));
}
#[test]
fn a_reference_is_a_fraction_of_the_inlined_markup() {
let set = IconSet::new();
let inline = set.inline("users", None).unwrap();
let reference = set
.use_ref("users", "/admin/assets/icons.abc.svg", None)
.unwrap();
assert!(
reference.len() * 3 < inline.len(),
"a reference ({} bytes) should be far smaller than inlining ({} bytes)",
reference.len(),
inline.len()
);
assert!(reference.contains("#lat-users"));
}
#[test]
fn a_namespaced_icon_gets_a_url_safe_id() {
let mut set = IconSet::new();
let svg = r#"<svg viewBox="0 0 24 24"><path d="M1 1"/></svg>"#;
set.add("rainmill.location", &IconReg::new("pin", svg))
.unwrap();
assert!(set.sprite().contains(r#"id="lat-rainmill.location--pin""#));
let r = set
.use_ref("rainmill.location:pin", "/s.svg", None)
.unwrap();
assert!(r.contains("#lat-rainmill.location--pin"));
}
#[test]
fn an_unknown_name_yields_no_reference() {
assert!(IconSet::new().use_ref("nope", "/s.svg", None).is_none());
}
#[test]
fn direction_carrying_icons_are_marked_for_rtl() {
let set = IconSet::new();
assert!(set
.inline("chevron-right", None)
.unwrap()
.contains("data-mirror"));
assert!(!set.inline("clock", None).unwrap().contains("data-mirror"));
}
}