use regex::Regex;
use std::sync::LazyLock;
static ID_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|\s)#([\w-]+)").expect("literal ID regex is valid and cannot fail to compile")
});
static CLASS_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|\s)\.([\w-]+)")
.expect("literal class regex is valid and cannot fail to compile")
});
static ATTR_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"([\w-]+)=(?:"([^"]*)"|[\u{201C}]([^\u{201D}]*)[\u{201D}]|(\S+))"#)
.expect("literal attr regex is valid and cannot fail to compile")
});
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ParsedAttrs {
pub id: Option<String>,
pub classes: Vec<String>,
pub attrs: Vec<(String, Option<String>)>,
}
impl ParsedAttrs {
pub fn parse(input: &str) -> Option<Self> {
let trimmed = input.trim();
if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
return None;
}
let content = &trimmed[1..trimmed.len() - 1];
let mut result = ParsedAttrs::default();
for cap in ID_PATTERN.captures_iter(content) {
result.id = Some(cap[1].to_string());
}
result.classes = CLASS_PATTERN
.captures_iter(content)
.map(|cap| cap[1].to_string())
.collect();
result.attrs = ATTR_PATTERN
.captures_iter(content)
.map(|cap| {
let key = cap[1].to_string();
let value = cap
.get(2)
.or_else(|| cap.get(3))
.or_else(|| cap.get(4))
.map(|m| m.as_str().to_string());
(key, value)
})
.collect();
Some(result)
}
pub fn to_html_attr_string(&self) -> String {
if self.is_empty() {
return String::new();
}
let mut parts = Vec::new();
if let Some(id) = &self.id {
parts.push(format!(r#"id="{}""#, html_escape(id)));
}
if !self.classes.is_empty() {
let classes_str = self
.classes
.iter()
.map(|c| html_escape(c))
.collect::<Vec<_>>()
.join(" ");
parts.push(format!(r#"class="{}""#, classes_str));
}
for (key, value) in &self.attrs {
match value {
Some(v) => parts.push(format!(r#"{}="{}""#, html_escape(key), html_escape(v))),
None => parts.push(html_escape(key)),
}
}
if parts.is_empty() {
String::new()
} else {
format!(" {}", parts.join(" "))
}
}
pub fn is_empty(&self) -> bool {
self.id.is_none() && self.classes.is_empty() && self.attrs.is_empty()
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_id() {
let attrs = ParsedAttrs::parse("{#my-id}").unwrap();
assert_eq!(attrs.id, Some("my-id".to_string()));
assert!(attrs.classes.is_empty());
assert!(attrs.attrs.is_empty());
}
#[test]
fn test_parse_classes() {
let attrs = ParsedAttrs::parse("{.foo .bar}").unwrap();
assert_eq!(attrs.id, None);
assert_eq!(attrs.classes, vec!["foo", "bar"]);
assert!(attrs.attrs.is_empty());
}
#[test]
fn test_parse_single_class() {
let attrs = ParsedAttrs::parse("{.highlight}").unwrap();
assert_eq!(attrs.classes, vec!["highlight"]);
}
#[test]
fn test_parse_mixed() {
let attrs = ParsedAttrs::parse(r#"{#intro .highlight data-x="y"}"#).unwrap();
assert_eq!(attrs.id, Some("intro".to_string()));
assert_eq!(attrs.classes, vec!["highlight"]);
assert_eq!(
attrs.attrs,
vec![("data-x".to_string(), Some("y".to_string()))]
);
}
#[test]
fn test_parse_unquoted_value() {
let attrs = ParsedAttrs::parse("{data-transition=slide}").unwrap();
assert_eq!(
attrs.attrs,
vec![("data-transition".to_string(), Some("slide".to_string()))]
);
}
#[test]
fn test_parse_quoted_value_with_spaces() {
let attrs = ParsedAttrs::parse(r#"{title="Hello World"}"#).unwrap();
assert_eq!(
attrs.attrs,
vec![("title".to_string(), Some("Hello World".to_string()))]
);
}
#[test]
fn test_last_id_wins() {
let attrs = ParsedAttrs::parse("{#first #second}").unwrap();
assert_eq!(attrs.id, Some("second".to_string()));
}
#[test]
fn test_empty_attrs() {
let attrs = ParsedAttrs::parse("{}").unwrap();
assert!(attrs.is_empty());
}
#[test]
fn test_non_attrs_returns_none() {
assert!(ParsedAttrs::parse("hello world").is_none());
assert!(ParsedAttrs::parse("{incomplete").is_none());
assert!(ParsedAttrs::parse("no braces").is_none());
assert!(ParsedAttrs::parse("missing}").is_none());
}
#[test]
fn test_to_html_attr_string_id_only() {
let attrs = ParsedAttrs::parse("{#my-id}").unwrap();
assert_eq!(attrs.to_html_attr_string(), r#" id="my-id""#);
}
#[test]
fn test_to_html_attr_string_classes_only() {
let attrs = ParsedAttrs::parse("{.foo .bar}").unwrap();
assert_eq!(attrs.to_html_attr_string(), r#" class="foo bar""#);
}
#[test]
fn test_to_html_attr_string_mixed() {
let attrs = ParsedAttrs::parse(r#"{#intro .highlight data-bg="blue"}"#).unwrap();
assert_eq!(
attrs.to_html_attr_string(),
r#" id="intro" class="highlight" data-bg="blue""#
);
}
#[test]
fn test_to_html_attr_string_empty() {
let attrs = ParsedAttrs::parse("{}").unwrap();
assert_eq!(attrs.to_html_attr_string(), "");
}
#[test]
fn test_html_escaping() {
let attrs = ParsedAttrs::parse(r#"{#test data-val="<script>"}"#).unwrap();
assert_eq!(
attrs.to_html_attr_string(),
r#" id="test" data-val="<script>""#
);
}
#[test]
fn test_whitespace_handling() {
let attrs = ParsedAttrs::parse(" {#my-id} ").unwrap();
assert_eq!(attrs.id, Some("my-id".to_string()));
}
#[test]
fn test_complex_attrs() {
let attrs = ParsedAttrs::parse(
r##"{#section-1 .slide .center data-transition="slide" data-background-color="#fff"}"##,
)
.unwrap();
assert_eq!(attrs.id, Some("section-1".to_string()));
assert_eq!(attrs.classes, vec!["slide", "center"]);
assert_eq!(attrs.attrs.len(), 2);
}
#[test]
fn test_curly_quotes() {
let input = "{data-transition=\u{201C}slide\u{201D}}";
let attrs = ParsedAttrs::parse(input).unwrap();
assert_eq!(
attrs.attrs,
vec![("data-transition".to_string(), Some("slide".to_string()))]
);
}
#[test]
fn test_curly_quotes_html_output() {
let input = "{data-bg=\u{201C}blue\u{201D}}";
let attrs = ParsedAttrs::parse(input).unwrap();
assert_eq!(attrs.to_html_attr_string(), r#" data-bg="blue""#);
}
}