vexy-vsvg-plugin-sdk 2.4.2

Plugin SDK for vexy-vsvg
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
// this_file: crates/vexy-vsvg-plugin-sdk/src/plugins/add_attributes_to_svg_element.rs

//! Injects custom attributes into the root `<svg>` element.
//!
//! Useful for adding metadata, aria labels, data attributes, or other custom properties
//! to the outermost SVG tag. Won't overwrite existing attributes.
//!
//! **What it does:**
//! - Adds attributes to the root `<svg>` element only
//! - Skips attributes the element already has (preserves existing values)
//! - Accepts either attribute names (with empty values) or name-value pairs
//!
//! **Configuration:**
//! - `attribute`: Single attribute (string or object)
//! - `attributes`: Array of attributes (strings or objects)
//!
//! At least one must be provided.
//!
//! **Example:**
//! ```json
//! {
//!   "attribute": "data-icon",
//!   "attributes": [
//!     "aria-hidden=true",
//!     {"role": "img", "data-testid": "icon"}
//!   ]
//! }
//! ```
//!
//! ```xml
//! <!-- Before -->
//! <svg viewBox="0 0 100 100">...</svg>
//!
//! <!-- After -->
//! <svg viewBox="0 0 100 100" data-icon="" aria-hidden="true" role="img" data-testid="icon">...</svg>
//! ```
//!
//! **Why it's useful:** Adds semantic metadata or framework-specific attributes
//! (React data attributes, accessibility labels) without manual editing.
//!
//! Reference: SVGO addAttributesToSVGElement plugin

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use vexy_vsvg::ast::{Document, Element};
use vexy_vsvg::Plugin;

/// Attribute value format: either a simple string or a name-value map.
///
/// - `String`: Attribute name only (e.g., `"data-icon"`) or name=value pair (e.g., `"role=img"`)
/// - `Object`: Map of attribute names to values (e.g., `{"role": "img", "data-id": "123"}`)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AttributeValue {
    String(String),
    Object(HashMap<String, String>),
}

/// Configuration for the addAttributesToSVGElement plugin.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AddAttributesToSVGElementConfig {
    /// Single attribute to add (optional).
    pub attribute: Option<AttributeValue>,
    /// Multiple attributes to add (optional).
    ///
    /// At least one of `attribute` or `attributes` must be provided.
    pub attributes: Option<Vec<AttributeValue>>,
}

/// Plugin to add attributes to the root SVG element.
///
/// Injects custom attributes into the outermost `<svg>` tag without overwriting
/// existing ones. Useful for metadata, accessibility, and framework integration.
#[derive(Debug, Clone)]
pub struct AddAttributesToSVGElementPlugin {
    config: AddAttributesToSVGElementConfig,
}

impl AddAttributesToSVGElementPlugin {
    pub fn new() -> Self {
        Self {
            config: AddAttributesToSVGElementConfig::default(),
        }
    }

    pub fn with_config(config: AddAttributesToSVGElementConfig) -> Self {
        Self { config }
    }

    pub fn parse_config(params: &Value) -> Result<AddAttributesToSVGElementConfig, anyhow::Error> {
        let config: AddAttributesToSVGElementConfig = serde_json::from_value(params.clone())?;
        Ok(config)
    }

    /// Apply configured attributes to the element.
    ///
    /// Processes both single `attribute` and multiple `attributes` configurations.
    fn apply_attributes(&self, element: &mut Element) {
        // Apply single attribute if specified
        if let Some(ref attr) = self.config.attribute {
            self.apply_attribute_value(element, attr);
        }

        // Apply multiple attributes if specified
        if let Some(ref attrs) = self.config.attributes {
            for attr in attrs {
                self.apply_attribute_value(element, attr);
            }
        }
    }

    /// Add a single attribute value to the element.
    ///
    /// Handles both string format (`"name"` or `"name=value"`) and object format
    /// (`{"name": "value"}`). Won't overwrite existing attributes.
    fn apply_attribute_value(&self, element: &mut Element, attr: &AttributeValue) {
        match attr {
            AttributeValue::String(name) => {
                if let Some((attr_name, attr_value)) = name.split_once('=') {
                    if !element.attributes.contains_key(attr_name) {
                        element
                            .attributes
                            .insert(attr_name.to_string().into(), attr_value.to_string().into());
                    }
                } else if !element.attributes.contains_key(name.as_str()) {
                    // Add attribute name with empty value if it doesn't exist
                    element
                        .attributes
                        .insert(name.clone().into(), String::new().into());
                }
            }
            AttributeValue::Object(attrs) => {
                // Add each attribute-value pair if the attribute doesn't exist
                for (name, value) in attrs {
                    if !element.attributes.contains_key(name.as_str()) {
                        element
                            .attributes
                            .insert(name.clone().into(), value.clone().into());
                    }
                }
            }
        }
    }
}

impl Default for AddAttributesToSVGElementPlugin {
    fn default() -> Self {
        Self::new()
    }
}

impl Plugin for AddAttributesToSVGElementPlugin {
    fn name(&self) -> &'static str {
        "addAttributesToSVGElement"
    }

    fn description(&self) -> &'static str {
        "adds attributes to an outer <svg> element"
    }

    fn validate_params(&self, params: &Value) -> anyhow::Result<()> {
        let config = Self::parse_config(params)?;

        // Validate that at least one of attribute or attributes is specified
        if config.attribute.is_none() && config.attributes.is_none() {
            return Err(anyhow::anyhow!(
                "Error in plugin \"addAttributesToSVGElement\": absent parameters.\n\
                It should have a list of \"attributes\" or one \"attribute\"."
            ));
        }

        Ok(())
    }

    fn configure(&mut self, params: &Value) -> anyhow::Result<()> {
        self.config = Self::parse_config(params)?;
        Ok(())
    }

    fn apply(&self, document: &mut Document) -> anyhow::Result<()> {
        // Only apply to root SVG element
        if document.root.name == "svg" {
            self.apply_attributes(&mut document.root);
        }
        Ok(())
    }
}

#[cfg(test)]
mod unit_tests {
    use super::*;
    use serde_json::json;
    use std::borrow::Cow;
    use vexy_vsvg::ast::{Document, Element};

    fn create_element(name: &'static str) -> Element<'static> {
        let mut element = Element::new(name);
        element.name = Cow::Borrowed(name);
        element
    }

    #[test]
    fn test_plugin_creation() {
        let plugin = AddAttributesToSVGElementPlugin::new();
        assert_eq!(plugin.name(), "addAttributesToSVGElement");
        assert_eq!(
            plugin.description(),
            "adds attributes to an outer <svg> element"
        );
    }

    #[test]
    fn test_parameter_validation_missing_params() {
        let plugin = AddAttributesToSVGElementPlugin::new();

        // Invalid - no parameters
        assert!(plugin.validate_params(&json!({})).is_err());
    }

    #[test]
    fn test_parameter_validation_single_attribute() {
        let plugin = AddAttributesToSVGElementPlugin::new();

        // Valid - single attribute as string
        assert!(plugin
            .validate_params(&json!({
                "attribute": "myAttribute"
            }))
            .is_ok());

        // Valid - single attribute as object
        assert!(plugin
            .validate_params(&json!({
                "attribute": {"data-name": "value"}
            }))
            .is_ok());
    }

    #[test]
    fn test_parameter_validation_multiple_attributes() {
        let plugin = AddAttributesToSVGElementPlugin::new();

        // Valid - array of attributes
        assert!(plugin
            .validate_params(&json!({
                "attributes": ["attr1", "attr2"]
            }))
            .is_ok());

        // Valid - array with mixed types
        assert!(plugin
            .validate_params(&json!({
                "attributes": ["attr1", {"data-name": "value"}]
            }))
            .is_ok());
    }

    #[test]
    fn test_add_single_string_attribute() {
        let config = AddAttributesToSVGElementConfig {
            attribute: Some(AttributeValue::String("myAttribute".to_string())),
            attributes: None,
        };
        let plugin = AddAttributesToSVGElementPlugin::with_config(config);

        let mut doc = Document::new();
        doc.root = create_element("svg");

        // Apply plugin
        plugin.apply(&mut doc).unwrap();

        // Check that attribute was added with empty value
        assert_eq!(doc.root.attr("myAttribute"), Some(""));
    }

    #[test]
    fn test_add_single_object_attribute() {
        let mut attrs = HashMap::new();
        attrs.insert("data-name".to_string(), "myValue".to_string());
        attrs.insert("data-id".to_string(), "123".to_string());

        let config = AddAttributesToSVGElementConfig {
            attribute: Some(AttributeValue::Object(attrs)),
            attributes: None,
        };
        let plugin = AddAttributesToSVGElementPlugin::with_config(config);

        let mut doc = Document::new();
        doc.root = create_element("svg");

        // Apply plugin
        plugin.apply(&mut doc).unwrap();

        // Check that attributes were added
        assert_eq!(doc.root.attr("data-name"), Some("myValue"));
        assert_eq!(doc.root.attr("data-id"), Some("123"));
    }

    #[test]
    fn test_add_multiple_attributes() {
        let mut object_attrs = HashMap::new();
        object_attrs.insert("data-test".to_string(), "value".to_string());

        let config = AddAttributesToSVGElementConfig {
            attribute: None,
            attributes: Some(vec![
                AttributeValue::String("class".to_string()),
                AttributeValue::Object(object_attrs),
                AttributeValue::String("id".to_string()),
            ]),
        };
        let plugin = AddAttributesToSVGElementPlugin::with_config(config);

        let mut doc = Document::new();
        doc.root = create_element("svg");

        // Apply plugin
        plugin.apply(&mut doc).unwrap();

        // Check that all attributes were added
        assert_eq!(doc.root.attr("class"), Some(""));
        assert_eq!(doc.root.attr("id"), Some(""));
        assert_eq!(doc.root.attr("data-test"), Some("value"));
    }

    #[test]
    fn test_does_not_override_existing_attributes() {
        let config = AddAttributesToSVGElementConfig {
            attribute: Some(AttributeValue::String("class".to_string())),
            attributes: None,
        };
        let plugin = AddAttributesToSVGElementPlugin::with_config(config);

        let mut doc = Document::new();
        doc.root = create_element("svg");
        doc.root.set_attr("class", "existing-class");

        // Apply plugin
        plugin.apply(&mut doc).unwrap();

        // Check that existing attribute was not overridden
        assert_eq!(doc.root.attr("class"), Some("existing-class"));
    }

    #[test]
    fn test_only_applies_to_svg_element() {
        let config = AddAttributesToSVGElementConfig {
            attribute: Some(AttributeValue::String("myAttr".to_string())),
            attributes: None,
        };
        let plugin = AddAttributesToSVGElementPlugin::with_config(config);

        let mut doc = Document::new();
        doc.root = create_element("div"); // Not an SVG element

        // Apply plugin
        plugin.apply(&mut doc).unwrap();

        // Check that no attributes were added
        assert_eq!(doc.root.attributes.len(), 0);
    }

    #[test]
    fn test_both_attribute_and_attributes() {
        let mut object_attrs = HashMap::new();
        object_attrs.insert("data-value".to_string(), "test".to_string());

        let config = AddAttributesToSVGElementConfig {
            attribute: Some(AttributeValue::String("single".to_string())),
            attributes: Some(vec![
                AttributeValue::String("multiple".to_string()),
                AttributeValue::Object(object_attrs),
            ]),
        };
        let plugin = AddAttributesToSVGElementPlugin::with_config(config);

        let mut doc = Document::new();
        doc.root = create_element("svg");

        // Apply plugin
        plugin.apply(&mut doc).unwrap();

        // Check that all attributes were added
        assert_eq!(doc.root.attr("single"), Some(""));
        assert_eq!(doc.root.attr("multiple"), Some(""));
        assert_eq!(doc.root.attr("data-value"), Some("test"));
    }

    #[test]
    fn test_config_parsing() {
        // Test single string attribute
        let config = AddAttributesToSVGElementPlugin::parse_config(&json!({
            "attribute": "test"
        }))
        .unwrap();

        if let Some(AttributeValue::String(s)) = config.attribute {
            assert_eq!(s, "test");
        } else {
            panic!("Expected string attribute");
        }

        // Test array of attributes
        let config = AddAttributesToSVGElementPlugin::parse_config(&json!({
            "attributes": ["attr1", {"key": "value"}]
        }))
        .unwrap();

        if let Some(attrs) = config.attributes {
            assert_eq!(attrs.len(), 2);
        } else {
            panic!("Expected attributes array");
        }
    }
}

// Custom fixture tests to handle valueless attributes
#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use vexy_vsvg::Config;
    use vexy_vsvg_test_utils::load_fixtures;

    #[test]
    fn fixture_tests_with_params() -> Result<(), Box<dyn std::error::Error>> {
        let fixtures_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("..")
            .join("..")
            .join("testdata")
            .join("plugins")
            .join("addAttributesToSVGElement");

        if !fixtures_path.exists() {
            println!("No fixtures found for plugin: addAttributesToSVGElement");
            return Ok(());
        }

        let fixtures = load_fixtures(&fixtures_path)?;

        for fixture in fixtures {
            let mut config = Config::new();
            config.plugins = vec![vexy_vsvg::PluginConfig::Name(
                "addAttributesToSVGElement".to_string(),
            )];
            if let Some(params) = fixture.params {
                config.configure_plugin("addAttributesToSVGElement", params);
            }
            config.js2svg.pretty = true;
            config.js2svg.indent = "    ".to_string();
            config.js2svg.final_newline = false;

            let registry = crate::registry::create_migrated_plugin_registry();
            let options = vexy_vsvg::OptimizeOptions::new(config).with_registry(registry);
            let result = vexy_vsvg::optimize(&fixture.input, options)?;

            // Normalize output: vexy-vsvg produces XML-valid 'data-icon=""', but SVGO fixture expects 'data-icon'
            let normalized_data = result.data.replace("data-icon=\"\"", "data-icon");

            assert_eq!(
                normalized_data, fixture.expected,
                "Fixture: {}",
                fixture.name
            );
        }
        Ok(())
    }
}