fallow_extract/
iconify.rs1use std::path::Path;
18use std::sync::LazyLock;
19
20use regex::Regex;
21
22static ICON_PROP: LazyLock<Regex> = LazyLock::new(|| {
32 crate::static_regex(r#"[\s"'/](?:icon|name)\s*=\s*["']([a-z0-9]+(?:-[a-z0-9]+)*):[a-z0-9]"#)
33});
34
35static NUXT_UI_ICON_PROP: LazyLock<Regex> = LazyLock::new(|| {
43 crate::static_regex(
44 r#"(?m)(?:^|[,{]\s*)(?:icon|["']icon["'])\s*:\s*["']i-([a-z0-9]+(?:-[a-z0-9]+)+)["']"#,
45 )
46});
47
48static HTML_COMMENT: LazyLock<Regex> = LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
54
55const MARKUP_EXTENSIONS: &[&str] = &["astro", "jsx", "tsx", "svelte", "vue", "html", "htm", "mdx"];
60
61fn is_markup_path(path: &Path) -> bool {
62 path.extension()
63 .and_then(|ext| ext.to_str())
64 .is_some_and(|ext| MARKUP_EXTENSIONS.contains(&ext))
65}
66
67fn is_vue_path(path: &Path) -> bool {
68 path.extension().and_then(|ext| ext.to_str()) == Some("vue")
69}
70
71#[must_use]
74pub fn extract_iconify_prefixes(path: &Path, source: &str) -> Vec<String> {
75 if !is_markup_path(path) {
76 return Vec::new();
77 }
78
79 let scanned = HTML_COMMENT.replace_all(source, "");
80 let mut prefixes: Vec<String> = ICON_PROP
81 .captures_iter(&scanned)
82 .map(|caps| caps[1].to_string())
83 .collect();
84 prefixes.sort_unstable();
85 prefixes.dedup();
86 prefixes
87}
88
89#[must_use]
93pub fn extract_iconify_icon_names(path: &Path, source: &str) -> Vec<String> {
94 if !is_vue_path(path) {
95 return Vec::new();
96 }
97
98 let mut names: Vec<String> = NUXT_UI_ICON_PROP
99 .captures_iter(source)
100 .map(|caps| caps[1].to_string())
101 .collect();
102 names.sort_unstable();
103 names.dedup();
104 names
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use std::path::Path;
111
112 fn prefixes(source: &str) -> Vec<String> {
113 extract_iconify_prefixes(Path::new("src/pages/index.astro"), source)
114 }
115
116 fn icon_names(source: &str) -> Vec<String> {
117 extract_iconify_icon_names(Path::new("app/layouts/default.vue"), source)
118 }
119
120 #[test]
121 fn extracts_name_prop_double_quoted() {
122 assert_eq!(prefixes(r#"<Icon name="jam:github" />"#), vec!["jam"]);
123 }
124
125 #[test]
126 fn extracts_icon_prop_single_quoted() {
127 assert_eq!(prefixes(r"<List icon='ic:round-home' />"), vec!["ic"]);
128 }
129
130 #[test]
131 fn dedupes_and_sorts_multiple_icons() {
132 let source = r#"
133 <Icon name="jam:github" />
134 <Icon name="jam:linkedin" />
135 <List icon="ic:round-home" />
136 "#;
137 assert_eq!(prefixes(source), vec!["ic", "jam"]);
138 }
139
140 #[test]
141 fn handles_hyphenated_collection_prefixes() {
142 let source = r#"<Icon name="simple-icons:github" /><Icon icon="fa6-solid:house" />"#;
143 assert_eq!(prefixes(source), vec!["fa6-solid", "simple-icons"]);
144 }
145
146 #[test]
147 fn ignores_attribute_names_that_merely_end_in_name() {
148 assert!(prefixes(r#"<div data-name="jam:github" />"#).is_empty());
149 assert!(prefixes(r#"<a filename="ic:home" />"#).is_empty());
150 }
151
152 #[test]
153 fn ignores_values_without_a_colon_prefix() {
154 assert!(prefixes(r#"<input name="email" />"#).is_empty());
155 assert!(prefixes(r#"<Icon name="github" />"#).is_empty());
156 }
157
158 #[test]
159 fn ignores_bare_prefix_with_no_icon_name() {
160 assert!(prefixes(r#"<Icon name="jam:" />"#).is_empty());
161 }
162
163 #[test]
164 fn ignores_dynamic_bindings() {
165 assert!(prefixes(r#"<Icon :name="iconExpr" />"#).is_empty());
166 assert!(prefixes(r"<Icon name={iconExpr} />").is_empty());
167 }
168
169 #[test]
170 fn ignores_icons_inside_html_comments() {
171 assert!(prefixes(r#"<!-- <Icon name="jam:github" /> -->"#).is_empty());
172 let source = "<!--\n <List icon=\"ic:round-home\" />\n-->\n<Icon name=\"mdi:home\" />";
173 assert_eq!(prefixes(source), vec!["mdi"]);
174 }
175
176 #[test]
177 fn returns_empty_for_non_markup_extensions() {
178 let prefixes = extract_iconify_prefixes(
179 Path::new("src/util.ts"),
180 r#"const x = { name: "jam:github" };"#,
181 );
182 assert!(prefixes.is_empty());
183 }
184
185 #[test]
186 fn extracts_nuxt_ui_script_icon_property() {
187 let source = r#"
188 const links = [{
189 label: 'View page source',
190 icon: 'i-simple-icons-github'
191 }, {
192 "icon": "i-lucide-house"
193 }]
194 "#;
195 assert_eq!(
196 icon_names(source),
197 vec!["lucide-house", "simple-icons-github"]
198 );
199 }
200
201 #[test]
202 fn ignores_nuxt_ui_icon_strings_without_icon_property() {
203 let source = r"
204 const links = [{
205 label: 'i-simple-icons-github',
206 iconName: 'i-lucide-house'
207 }]
208 ";
209 assert!(icon_names(source).is_empty());
210 }
211
212 #[test]
213 fn ignores_nuxt_ui_icon_names_outside_vue_files() {
214 let names = extract_iconify_icon_names(
215 Path::new("app/navigation.ts"),
216 r"const link = { icon: 'i-simple-icons-github' }",
217 );
218 assert!(names.is_empty());
219 }
220}