logicaffeine_cli/browser/
snapshot.rs1use serde::{Deserialize, Serialize};
5
6#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum DomNode {
13 Text {
14 text: String,
15 },
16 Element {
17 tag: String,
18 attrs: Vec<(String, String)>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 value: Option<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 checked: Option<bool>,
23 children: Vec<DomNode>,
24 },
25}
26
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Snapshot {
30 pub url: String,
31 pub title: String,
32 pub focus: Option<String>,
35 pub document: DomNode,
36}
37
38impl DomNode {
39 fn is_formatting_whitespace(&self) -> bool {
40 matches!(self, DomNode::Text { text } if text.chars().all(char::is_whitespace))
41 }
42
43 pub fn children(&self) -> &[DomNode] {
45 match self {
46 DomNode::Element { children, .. } => children,
47 DomNode::Text { .. } => &[],
48 }
49 }
50
51 pub fn tag(&self) -> Option<&str> {
53 match self {
54 DomNode::Element { tag, .. } => Some(tag),
55 DomNode::Text { .. } => None,
56 }
57 }
58
59 pub fn text_content(&self) -> String {
61 match self {
62 DomNode::Text { text } => text.clone(),
63 DomNode::Element { children, .. } => {
64 children.iter().map(DomNode::text_content).collect()
65 }
66 }
67 }
68
69 pub fn markup(&self) -> String {
73 let mut out = String::new();
74 self.write_markup(&mut out);
75 out
76 }
77
78 fn write_markup(&self, out: &mut String) {
79 use logicaffeine_language::lgx::html::{escape_attr, escape_text, is_raw_text, is_void};
80 match self {
81 DomNode::Text { text } => out.push_str(&escape_text(text)),
82 DomNode::Element {
83 tag,
84 attrs,
85 value,
86 checked,
87 children,
88 } => {
89 out.push('<');
90 out.push_str(tag);
91 for (k, v) in attrs {
92 out.push_str(&format!(" {k}=\"{}\"", escape_attr(v)));
93 }
94 if *checked == Some(true) {
95 out.push_str(" checked");
96 }
97 if let Some(v) = value {
98 out.push_str(&format!(" value=\"{}\"", escape_attr(v)));
99 }
100 out.push('>');
101 if is_void(tag) {
102 return;
103 }
104 for c in children {
105 match (is_raw_text(tag), c) {
106 (true, DomNode::Text { text }) => out.push_str(text),
107 _ => c.write_markup(out),
108 }
109 }
110 out.push_str(&format!("</{tag}>"));
111 }
112 }
113 }
114}
115
116impl Snapshot {
117 pub fn body(&self) -> Option<&DomNode> {
119 self.document
120 .children()
121 .iter()
122 .find(|c| c.tag() == Some("body"))
123 }
124
125 pub fn head(&self) -> Option<&DomNode> {
127 self.document
128 .children()
129 .iter()
130 .find(|c| c.tag() == Some("head"))
131 }
132
133 pub fn body_markup(&self) -> String {
140 let Some(DomNode::Element {
141 attrs,
142 value,
143 checked,
144 children,
145 ..
146 }) = self.body()
147 else {
148 return String::new();
149 };
150 let content: Vec<DomNode> = children
151 .iter()
152 .filter(|c| !c.is_formatting_whitespace())
153 .cloned()
154 .collect();
155 DomNode::Element {
156 tag: "body".into(),
157 attrs: attrs.clone(),
158 value: value.clone(),
159 checked: *checked,
160 children: content,
161 }
162 .markup()
163 }
164
165 pub fn head_markup(&self) -> Option<String> {
169 self.head_markup_without_stylesheets(&[])
170 }
171
172 pub fn head_markup_without_stylesheets(&self, hrefs: &[String]) -> Option<String> {
176 let shell = |c: &DomNode| match c {
177 DomNode::Element { tag, attrs, .. } if tag == "link" => {
178 let attr = |name: &str| {
179 attrs
180 .iter()
181 .find(|(k, _)| k == name)
182 .map(|(_, v)| v.as_str())
183 };
184 attr("rel") == Some("stylesheet")
185 && attr("href").is_some_and(|h| hrefs.iter().any(|s| s == h))
186 }
187 _ => false,
188 };
189 let markup: String = self
190 .head()?
191 .children()
192 .iter()
193 .filter(|c| c.tag().is_some() && !shell(c))
194 .map(DomNode::markup)
195 .collect();
196 (!markup.is_empty()).then_some(markup)
197 }
198
199 pub fn by_id(&self, id: &str) -> Vec<&DomNode> {
201 fn walk<'s>(n: &'s DomNode, id: &str, out: &mut Vec<&'s DomNode>) {
202 if let DomNode::Element {
203 attrs, children, ..
204 } = n
205 {
206 if attrs.iter().any(|(k, v)| k == "id" && v == id) {
207 out.push(n);
208 }
209 for c in children {
210 walk(c, id, out);
211 }
212 }
213 }
214 let mut out = Vec::new();
215 walk(&self.document, id, &mut out);
216 out
217 }
218
219 pub fn to_json(&self) -> String {
221 serde_json::to_string(self).expect("a snapshot serializes")
222 }
223}