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
9pub const PROPS_FUNCTION: &str = "privatusPageProps";
11
12pub 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
16pub const MARKER: &str = "/js/pa.js";
18
19#[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 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 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 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 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 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 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 pub fn spa(self, spa: Spa) -> Self {
107 self.edit(|c| c.spa(spa))
108 }
109
110 pub fn manual(self, manual: bool) -> Self {
112 self.edit(|c| c.manual(manual))
113 }
114
115 pub fn enabled(self, enabled: bool) -> Self {
117 self.edit(|c| c.enabled(enabled))
118 }
119
120 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 pub fn queue_stub(self, queue_stub: bool) -> Self {
128 self.edit(|c| c.queue_stub(queue_stub))
129 }
130
131 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 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 pub fn config(&self) -> &Config {
154 &self.config
155 }
156
157 pub fn will_render(&self) -> bool {
160 self.config.is_enabled() && self.config.resolved_site_id().is_some()
161 }
162
163 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 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 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 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
276pub 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#[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}