privatus_analytics/
attrs.rs1use crate::html::{escape_html, truncate_chars, SafeHtml};
4use crate::value::Value;
5
6pub const MAX_NAME: usize = 120;
8pub const MAX_KEY: usize = 64;
10pub const MAX_PROPS: usize = 30;
12pub const MAX_VALUE: usize = 500;
14pub const MAX_CLICK_NAME: usize = 100;
16
17pub fn event_attrs<I, K, V>(name: &str, props: I) -> SafeHtml
36where
37 I: IntoIterator<Item = (K, V)>,
38 K: AsRef<str>,
39 V: Into<Value>,
40{
41 attrs_to_html(&event_data(name, props))
42}
43
44pub fn event_data<I, K, V>(name: &str, props: I) -> Vec<(String, String)>
57where
58 I: IntoIterator<Item = (K, V)>,
59 K: AsRef<str>,
60 V: Into<Value>,
61{
62 let name = truncate_chars(name.trim(), MAX_NAME).trim();
63 if name.is_empty() {
64 return Vec::new();
65 }
66 let mut attrs = vec![("data-privatus-event".to_owned(), name.to_owned())];
67 for (key, value) in normalize(props) {
68 let text = string_value(&value);
69 attrs.push((
70 format!("data-privatus-prop-{key}"),
71 truncate_chars(&text, MAX_VALUE).to_owned(),
72 ));
73 }
74 attrs
75}
76
77pub fn click_attr(name: &str) -> SafeHtml {
90 attrs_to_html(&click_data(name))
91}
92
93pub fn click_data(name: &str) -> Vec<(String, String)> {
96 if valid_click(name) {
97 vec![("data-privatus-click".to_owned(), name.to_owned())]
98 } else {
99 warn_invalid_click(name);
100 Vec::new()
101 }
102}
103
104pub fn valid_click(name: &str) -> bool {
107 !name.is_empty()
108 && name.len() <= MAX_CLICK_NAME
109 && name
110 .bytes()
111 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
112}
113
114pub fn attrs_to_html(attrs: &[(String, String)]) -> SafeHtml {
116 let parts: Vec<String> = attrs
117 .iter()
118 .map(|(name, value)| format!("{name}=\"{}\"", escape_html(value)))
119 .collect();
120 SafeHtml::from_trusted(parts.join(" "))
121}
122
123#[cfg(debug_assertions)]
124fn warn_invalid_click(name: &str) {
125 eprintln!(
126 "[privatus-analytics] click map name {name:?} is not valid. Use letters, digits and \
127 _ . : - only, at most 100 characters. Nothing was printed."
128 );
129}
130
131#[cfg(not(debug_assertions))]
132fn warn_invalid_click(_name: &str) {}
133
134pub fn normalize_key(key: &str) -> String {
137 key.to_lowercase()
138 .chars()
139 .map(|c| {
140 if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' {
141 c
142 } else {
143 '_'
144 }
145 })
146 .take(MAX_KEY)
147 .collect()
148}
149
150pub(crate) fn normalize<I, K, V>(props: I) -> Vec<(String, Value)>
153where
154 I: IntoIterator<Item = (K, V)>,
155 K: AsRef<str>,
156 V: Into<Value>,
157{
158 let mut clean: Vec<(String, Value)> = Vec::new();
159 for (key, value) in props {
160 let key = normalize_key(key.as_ref());
161 let value = value.into();
162 if key.is_empty() || value == Value::Null {
163 continue;
164 }
165 match clean.iter_mut().find(|(k, _)| *k == key) {
166 Some(slot) => slot.1 = value,
167 None => clean.push((key, value)),
168 }
169 }
170 clean.truncate(MAX_PROPS);
171 clean
172}
173
174pub(crate) fn string_value(value: &Value) -> String {
176 match value {
177 Value::Null => String::new(),
178 Value::Bool(b) => b.to_string(),
179 Value::Int(n) => n.to_string(),
180 Value::UInt(n) => n.to_string(),
181 Value::Float(n) => n.to_string(),
182 Value::Str(s) => s.clone(),
183 Value::List(items) => items
184 .iter()
185 .filter(|item| **item != Value::Null)
186 .map(string_value)
187 .collect::<Vec<_>>()
188 .join(","),
189 }
190}