Skip to main content

privatus_analytics/
tag.rs

1use crate::attrs::normalize_key;
2use crate::config::{Config, Spa};
3use crate::html::{escape_html, SafeHtml};
4use crate::page_props::page_props_json;
5use crate::value::Value;
6use std::borrow::Cow;
7use std::fmt;
8
9/// The name of the page props function the tracker calls (`data-props`).
10pub const PROPS_FUNCTION: &str = "privatusPageProps";
11
12/// The tracker's official queue stub. It keeps `privatus.track` and the other
13/// calls in a list until `pa.js` has loaded.
14pub const QUEUE_STUB: &str = "window.privatus=window.privatus||{q:[]};['track','pageview','props','optOut','optIn','ready'].forEach(function(m){window.privatus[m]=window.privatus[m]||function(){window.privatus.q.push([m,Array.prototype.slice.call(arguments)])}})";
15
16/// Text that shows a page already has the tracker.
17pub const MARKER: &str = "/js/pa.js";
18
19/// The script tag for one page. Get one with [`Config::tag`].
20///
21/// Every setting can be changed for this page only, and page props set here
22/// are merged over the configured ones. A [`Value::Null`] (or `None`) removes
23/// one.
24///
25/// ```
26/// use privatus_analytics::{props, Config};
27///
28/// let config = Config::new().site_id("pa_YOURSITEID").enabled(true);
29/// let html = config
30///     .tag()
31///     .modules(["engage", "auto"])
32///     .props(props! { "plan" => "pro" })
33///     .nonce("r4nd0m")
34///     .to_html();
35/// assert!(html.as_str().contains(r#"data-modules="engage,auto""#));
36/// ```
37///
38/// `Tag` implements `Display`, so `format!("{tag}")` and `{{ tag }}` style
39/// templates print the HTML without escaping it again.
40#[derive(Clone, Debug)]
41pub struct Tag<'a> {
42    config: Cow<'a, Config>,
43    nonce: Option<String>,
44    props: Vec<(String, Value)>,
45}
46
47impl<'a> Tag<'a> {
48    /// The tag for these settings.
49    pub fn new(config: &'a Config) -> Self {
50        Tag {
51            config: Cow::Borrowed(config),
52            nonce: None,
53            props: Vec::new(),
54        }
55    }
56
57    fn edit(mut self, change: impl FnOnce(Config) -> Config) -> Self {
58        let config = std::mem::take(self.config.to_mut());
59        *self.config.to_mut() = change(config);
60        self
61    }
62
63    /// Uses another site id on this page.
64    pub fn site_id(self, site_id: impl Into<String>) -> Self {
65        let site_id = site_id.into();
66        self.edit(|c| c.site_id(site_id))
67    }
68
69    /// Uses another script host on this page.
70    pub fn script_host(self, host: impl Into<String>) -> Self {
71        let host = host.into();
72        self.edit(|c| c.script_host(host))
73    }
74
75    /// Loads these modules on this page.
76    pub fn modules<I, S>(self, modules: I) -> Self
77    where
78        I: IntoIterator<Item = S>,
79        S: Into<String>,
80    {
81        let modules: Vec<String> = modules.into_iter().map(Into::into).collect();
82        self.edit(|c| c.modules(modules))
83    }
84
85    /// Uses these excluded paths on this page.
86    pub fn excluded_paths<I, S>(self, paths: I) -> Self
87    where
88        I: IntoIterator<Item = S>,
89        S: Into<String>,
90    {
91        let paths: Vec<String> = paths.into_iter().map(Into::into).collect();
92        self.edit(|c| c.excluded_paths(paths))
93    }
94
95    /// Uses these domains on this page.
96    pub fn domains<I, S>(self, domains: I) -> Self
97    where
98        I: IntoIterator<Item = S>,
99        S: Into<String>,
100    {
101        let domains: Vec<String> = domains.into_iter().map(Into::into).collect();
102        self.edit(|c| c.domains(domains))
103    }
104
105    /// Uses this single-page app mode on this page.
106    pub fn spa(self, spa: Spa) -> Self {
107        self.edit(|c| c.spa(spa))
108    }
109
110    /// Turns manual mode on or off on this page.
111    pub fn manual(self, manual: bool) -> Self {
112        self.edit(|c| c.manual(manual))
113    }
114
115    /// Forces the tag on or off on this page.
116    pub fn enabled(self, enabled: bool) -> Self {
117        self.edit(|c| c.enabled(enabled))
118    }
119
120    /// Adds or replaces one extra attribute on this page.
121    pub fn extra_attribute(self, name: impl Into<String>, value: impl Into<String>) -> Self {
122        let (name, value) = (name.into(), value.into());
123        self.edit(|c| c.extra_attribute(name, value))
124    }
125
126    /// Prints the queue stub on this page, or not.
127    pub fn queue_stub(self, queue_stub: bool) -> Self {
128        self.edit(|c| c.queue_stub(queue_stub))
129    }
130
131    /// The Content Security Policy nonce for the scripts.
132    pub fn nonce(mut self, nonce: impl Into<String>) -> Self {
133        let nonce = nonce.into();
134        self.nonce = (!nonce.is_empty()).then_some(nonce);
135        self
136    }
137
138    /// Merges page props over the configured ones. A [`Value::Null`] (or
139    /// `None`) removes a prop. Can be called more than once.
140    pub fn props<I, K, V>(mut self, props: I) -> Self
141    where
142        I: IntoIterator<Item = (K, V)>,
143        K: AsRef<str>,
144        V: Into<Value>,
145    {
146        for (key, value) in props {
147            merge(&mut self.props, normalize_key(key.as_ref()), value.into());
148        }
149        self
150    }
151
152    /// The settings this tag uses, with the per-page changes.
153    pub fn config(&self) -> &Config {
154        &self.config
155    }
156
157    /// Whether the tracker prints: there is a site id and the tag is on. When
158    /// this is false, [`Tag::to_html`] prints at most the queue stub.
159    pub fn will_render(&self) -> bool {
160        self.config.is_enabled() && self.config.resolved_site_id().is_some()
161    }
162
163    /// The configured props with the per-page props merged on top. Keys are
164    /// cleaned. Null values are still in the list.
165    pub fn page_props(&self) -> Vec<(String, Value)> {
166        let mut merged = Vec::new();
167        for (key, value) in self.config.configured_props().iter() {
168            merge(&mut merged, normalize_key(key), value.clone());
169        }
170        for (key, value) in &self.props {
171            merge(&mut merged, key.clone(), value.clone());
172        }
173        merged
174    }
175
176    /// The tracker tag's attributes in order. `None` is an attribute with no
177    /// value (`defer`).
178    pub fn attributes(&self) -> Vec<(String, Option<String>)> {
179        let mut attrs: Vec<(String, Option<String>)> = vec![
180            ("defer".into(), None),
181            (
182                "src".into(),
183                Some(format!("{}{MARKER}", self.config.resolved_script_host())),
184            ),
185            (
186                "data-site".into(),
187                Some(self.config.resolved_site_id().unwrap_or_default()),
188            ),
189        ];
190        // None keeps the tracker default. An empty modules list loads none.
191        if let Some(modules) = self.config.modules_list() {
192            attrs.push(("data-modules".into(), Some(list(modules).join(","))));
193        }
194        let excluded = list(self.config.excluded_paths_list());
195        if !excluded.is_empty() {
196            attrs.push(("data-exclude".into(), Some(excluded.join(","))));
197        }
198        if let Some(domains) = self.config.domains_list() {
199            let domains = list(domains);
200            if !domains.is_empty() {
201                attrs.push(("data-domains".into(), Some(domains.join(","))));
202            }
203        }
204        if self.config.spa_mode() != Spa::Auto {
205            attrs.push(("data-spa".into(), Some(self.config.spa_mode().to_string())));
206        }
207        if self.config.is_manual() {
208            attrs.push(("data-manual".into(), Some("true".into())));
209        }
210        for (name, value) in self.config.extra_attributes_list() {
211            set_attr(&mut attrs, name.clone(), Some(value.clone()));
212        }
213        if let Some(nonce) = &self.nonce {
214            set_attr(&mut attrs, "nonce".into(), Some(nonce.clone()));
215        }
216        attrs
217    }
218
219    /// The HTML: the queue stub (when on), the page props script (when there
220    /// are props), then the tracker tag, one per line.
221    ///
222    /// When the tag is off or there is no site id, it is only the queue stub
223    /// (when on, with the nonce), so `privatus.track` calls in your own
224    /// JavaScript never fail; or empty when the stub is off. No tracker and no
225    /// page props script print then.
226    pub fn to_html(&self) -> SafeHtml {
227        if !self.will_render() {
228            if self.config.wants_queue_stub() {
229                return SafeHtml::from_trusted(self.inline_script(QUEUE_STUB));
230            }
231            return SafeHtml::default();
232        }
233        let props_json = page_props_json(self.page_props());
234        let mut parts = Vec::new();
235        if self.config.wants_queue_stub() {
236            parts.push(self.inline_script(QUEUE_STUB));
237        }
238        let mut attrs = self.attributes();
239        if let Some(json) = &props_json {
240            parts.push(self.inline_script(&format!(
241                "window.{PROPS_FUNCTION}=function(){{return {json}}}"
242            )));
243            set_attr(&mut attrs, "data-props".into(), Some(PROPS_FUNCTION.into()));
244        }
245        let rendered: Vec<String> = attrs
246            .iter()
247            .map(|(name, value)| match value {
248                None => name.clone(),
249                Some(value) => format!("{name}=\"{}\"", escape_html(value)),
250            })
251            .collect();
252        parts.push(format!("<script {}></script>", rendered.join(" ")));
253        SafeHtml::from_trusted(parts.join("\n"))
254    }
255
256    fn inline_script(&self, code: &str) -> String {
257        match &self.nonce {
258            Some(nonce) => format!("<script nonce=\"{}\">{code}</script>", escape_html(nonce)),
259            None => format!("<script>{code}</script>"),
260        }
261    }
262}
263
264impl fmt::Display for Tag<'_> {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        f.write_str(self.to_html().as_str())
267    }
268}
269
270impl From<Tag<'_>> for SafeHtml {
271    fn from(tag: Tag<'_>) -> SafeHtml {
272        tag.to_html()
273    }
274}
275
276/// Puts `tag` just before the first `</head>` (any case) in `html`.
277///
278/// Returns `None` when the page has no `</head>` or already has the tracker
279/// (it contains `/js/pa.js`). Use it to add the tag in a framework this crate
280/// has no middleware for.
281///
282/// ```
283/// use privatus_analytics::inject_tag;
284///
285/// let page = "<html><head><title>Hi</title></head><body></body></html>";
286/// let out = inject_tag(page, "<script defer src=\"https://privatusstats.com/js/pa.js\"></script>").unwrap();
287/// assert!(out.contains("pa.js\"></script>\n</head>"));
288/// assert_eq!(inject_tag(&out, "<script></script>"), None);
289/// ```
290pub fn inject_tag(html: &str, tag: &str) -> Option<String> {
291    if html.contains(MARKER) {
292        return None;
293    }
294    let at = find_ignore_ascii_case(html, "</head>")?;
295    let mut out = String::with_capacity(html.len() + tag.len() + 1);
296    out.push_str(&html[..at]);
297    out.push_str(tag);
298    out.push('\n');
299    out.push_str(&html[at..]);
300    Some(out)
301}
302
303fn find_ignore_ascii_case(haystack: &str, needle: &str) -> Option<usize> {
304    let hay = haystack.as_bytes();
305    let needle = needle.as_bytes();
306    if hay.len() < needle.len() {
307        return None;
308    }
309    (0..=hay.len() - needle.len()).find(|&i| hay[i..i + needle.len()].eq_ignore_ascii_case(needle))
310}
311
312/// Whether a response should get the tag: status 200, `text/html`, not
313/// compressed, and not an Ajax (`X-Requested-With: XMLHttpRequest`) request.
314#[allow(dead_code)]
315pub(crate) fn should_inject(
316    status: u16,
317    content_type: Option<&str>,
318    encoded: bool,
319    xhr: Option<&str>,
320) -> bool {
321    status == 200
322        && !encoded
323        && content_type.is_some_and(|t| t.trim().to_ascii_lowercase().starts_with("text/html"))
324        && !xhr.is_some_and(|x| x.trim().eq_ignore_ascii_case("XMLHttpRequest"))
325}
326
327fn list(values: &[String]) -> Vec<String> {
328    values
329        .iter()
330        .flat_map(|v| v.split(','))
331        .map(str::trim)
332        .filter(|v| !v.is_empty())
333        .map(str::to_owned)
334        .collect()
335}
336
337fn set_attr(attrs: &mut Vec<(String, Option<String>)>, name: String, value: Option<String>) {
338    match attrs.iter_mut().find(|(k, _)| *k == name) {
339        Some(slot) => slot.1 = value,
340        None => attrs.push((name, value)),
341    }
342}
343
344fn merge(props: &mut Vec<(String, Value)>, key: String, value: Value) {
345    match props.iter_mut().find(|(k, _)| *k == key) {
346        Some(slot) => slot.1 = value,
347        None => props.push((key, value)),
348    }
349}