Skip to main content

logicaffeine_cli/browser/
snapshot.rs

1//! A page's observable state at one moment: the document as a tree, its title, its URL, and
2//! which element has focus.
3
4use serde::{Deserialize, Serialize};
5
6/// One node of a snapshot document. An element carries its attributes sorted by name, and the
7/// two control PROPERTIES a visitor moves — `value` and `checked` — only where they differ from
8/// what the markup alone says (an untouched `<input value="x">` has no `value` entry; a typed-into
9/// one does).
10#[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/// The page at one moment, as the browser holds it.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Snapshot {
30    pub url: String,
31    pub title: String,
32    /// `/html[1]/body[1]/main[1]/input[2]` — the focused element's path, `None` when focus is on
33    /// the document itself.
34    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    /// The element's children, or nothing for a text node.
44    pub fn children(&self) -> &[DomNode] {
45        match self {
46            DomNode::Element { children, .. } => children,
47            DomNode::Text { .. } => &[],
48        }
49    }
50
51    /// The element's tag, or `None` for a text node.
52    pub fn tag(&self) -> Option<&str> {
53        match self {
54            DomNode::Element { tag, .. } => Some(tag),
55            DomNode::Text { .. } => None,
56        }
57    }
58
59    /// Everything a reader sees in this subtree, concatenated.
60    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    /// This subtree in the reference renderer's serialization (`semantics::dom::render`):
70    /// attributes in name order, then ` checked` and ` value="…"`, void elements unclosed,
71    /// raw-text elements verbatim, everything else escaped by the same functions.
72    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    /// The `<body>` element.
118    pub fn body(&self) -> Option<&DomNode> {
119        self.document
120            .children()
121            .iter()
122            .find(|c| c.tag() == Some("body"))
123    }
124
125    /// The `<head>` element.
126    pub fn head(&self) -> Option<&DomNode> {
127        self.document
128            .children()
129            .iter()
130            .find(|c| c.tag() == Some("head"))
131    }
132
133    /// The body in the reference renderer's serialization, so it compares byte for byte with
134    /// `semantics::dom::render()`.
135    ///
136    /// Whitespace-only text directly under `<body>` is the document's own formatting — the line
137    /// breaks around the markup a shell or a pre-render writes — and never page content, so it is
138    /// left out. Every other text node is kept exactly.
139    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    /// The head's elements in the reference renderer's serialization, in document order — the
166    /// whitespace text between them is the document's own formatting and is left out, as
167    /// [`Snapshot::body_markup`] leaves it out under `<body>`. `None` when the head holds no element.
168    pub fn head_markup(&self) -> Option<String> {
169        self.head_markup_without_stylesheets(&[])
170    }
171
172    /// [`Snapshot::head_markup`] without the `<link rel="stylesheet">` elements for `hrefs` — the
173    /// stylesheets a serving shell links for its project, which no page runtime on any tier
174    /// produces, so they are not the page's head.
175    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    /// Every element in document order whose `id` attribute is `id`.
200    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    /// The snapshot as canonical JSON.
220    pub fn to_json(&self) -> String {
221        serde_json::to_string(self).expect("a snapshot serializes")
222    }
223}