semantic_dom_ssg/
types.rs

1//! Core type definitions for SemanticDOM
2//!
3//! This module defines the semantic types used throughout the crate,
4//! including roles, intents, and node structures.
5
6use ahash::AHashMap;
7use serde::{Deserialize, Serialize};
8
9/// Semantic role for an element based on ARIA and HTML5 semantics
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum SemanticRole {
13    /// Navigation landmark
14    Navigation,
15    /// Main content area
16    Main,
17    /// Header/banner region
18    Header,
19    /// Footer/contentinfo region
20    Footer,
21    /// Aside/complementary content
22    Aside,
23    /// Article content
24    Article,
25    /// Section of content
26    Section,
27    /// Search region
28    Search,
29    /// Form element
30    Form,
31    /// Button element
32    Button,
33    /// Link/anchor element
34    Link,
35    /// Text input field
36    TextInput,
37    /// Checkbox input
38    Checkbox,
39    /// Radio button input
40    Radio,
41    /// Select/dropdown element
42    Select,
43    /// Heading element (h1-h6)
44    Heading,
45    /// List element (ul/ol)
46    List,
47    /// List item
48    ListItem,
49    /// Table element
50    Table,
51    /// Image element
52    Image,
53    /// Video element
54    Video,
55    /// Audio element
56    Audio,
57    /// Dialog/modal element
58    Dialog,
59    /// Alert/notification
60    Alert,
61    /// Menu element
62    Menu,
63    /// Tab element
64    Tab,
65    /// Tab panel
66    TabPanel,
67    /// Generic interactive element
68    Interactive,
69    /// Generic container
70    Container,
71    /// Unknown/other role
72    Unknown,
73}
74
75impl SemanticRole {
76    /// Check if this role represents a landmark region
77    pub fn is_landmark(&self) -> bool {
78        matches!(
79            self,
80            SemanticRole::Navigation
81                | SemanticRole::Main
82                | SemanticRole::Header
83                | SemanticRole::Footer
84                | SemanticRole::Aside
85                | SemanticRole::Search
86        )
87    }
88
89    /// Check if this role represents an interactive element
90    pub fn is_interactable(&self) -> bool {
91        matches!(
92            self,
93            SemanticRole::Button
94                | SemanticRole::Link
95                | SemanticRole::TextInput
96                | SemanticRole::Checkbox
97                | SemanticRole::Radio
98                | SemanticRole::Select
99                | SemanticRole::Interactive
100        )
101    }
102}
103
104/// User intent classification for an element
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum SemanticIntent {
108    /// Navigation action
109    Navigate,
110    /// Form submission
111    Submit,
112    /// Trigger an action
113    Action,
114    /// Toggle state
115    Toggle,
116    /// Select from options
117    Select,
118    /// Input data
119    Input,
120    /// Search functionality
121    Search,
122    /// Play media
123    Play,
124    /// Pause media
125    Pause,
126    /// Open/show content
127    Open,
128    /// Close/hide content
129    Close,
130    /// Expand collapsed content
131    Expand,
132    /// Collapse expanded content
133    Collapse,
134    /// Download resource
135    Download,
136    /// Delete/remove item
137    Delete,
138    /// Edit/modify item
139    Edit,
140    /// Create new item
141    Create,
142    /// Unknown intent
143    Unknown,
144}
145
146/// A semantic node in the DOM tree
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct SemanticNode {
149    /// Unique identifier for this node
150    pub id: String,
151    /// Human-readable label
152    pub label: String,
153    /// Semantic role
154    pub role: SemanticRole,
155    /// User intent (for interactables)
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub intent: Option<SemanticIntent>,
158    /// CSS selector path to this element
159    pub selector: String,
160    /// Accessible name from ARIA or content
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub accessible_name: Option<String>,
163    /// Target URL for links/navigation
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub href: Option<String>,
166    /// Child node IDs
167    #[serde(skip_serializing_if = "Vec::is_empty", default)]
168    pub children: Vec<String>,
169    /// Parent node ID
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub parent: Option<String>,
172    /// Additional metadata
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub metadata: Option<AHashMap<String, String>>,
175    /// Depth in the tree (0 = root)
176    pub depth: usize,
177}
178
179impl SemanticNode {
180    /// Create a new semantic node
181    pub fn new(id: String, label: String, role: SemanticRole, selector: String) -> Self {
182        Self {
183            id,
184            label,
185            role,
186            intent: None,
187            selector,
188            accessible_name: None,
189            href: None,
190            children: Vec::new(),
191            parent: None,
192            metadata: None,
193            depth: 0,
194        }
195    }
196}
197
198/// A state in the Semantic State Graph
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct State {
201    /// Unique state identifier
202    pub id: String,
203    /// Human-readable state name
204    pub name: String,
205    /// State description
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub description: Option<String>,
208    /// URL pattern for this state
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub url_pattern: Option<String>,
211    /// Whether this is the initial state
212    #[serde(default)]
213    pub is_initial: bool,
214    /// Whether this is a terminal state
215    #[serde(default)]
216    pub is_terminal: bool,
217}
218
219/// A transition between states in the SSG
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct Transition {
222    /// Source state ID
223    pub from: String,
224    /// Target state ID
225    pub to: String,
226    /// Trigger element ID
227    pub trigger: String,
228    /// Action description
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub action: Option<String>,
231    /// Guard condition
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub guard: Option<String>,
234}
235
236/// The Semantic State Graph
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct StateGraph {
239    /// All states in the graph
240    pub states: Vec<State>,
241    /// All transitions between states
242    pub transitions: Vec<Transition>,
243    /// Initial state ID
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub initial_state: Option<String>,
246}
247
248impl Default for StateGraph {
249    fn default() -> Self {
250        Self {
251            states: Vec::new(),
252            transitions: Vec::new(),
253            initial_state: None,
254        }
255    }
256}
257
258impl StateGraph {
259    /// Create a new empty state graph
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    /// Check if the graph is deterministic (no ambiguous transitions)
265    pub fn is_deterministic(&self) -> bool {
266        let mut seen: AHashMap<(&str, &str), bool> = AHashMap::new();
267        for t in &self.transitions {
268            let key = (t.from.as_str(), t.trigger.as_str());
269            if seen.contains_key(&key) {
270                return false;
271            }
272            seen.insert(key, true);
273        }
274        true
275    }
276
277    /// Find all states reachable from the initial state
278    pub fn reachable_states(&self) -> Vec<&State> {
279        let initial = match &self.initial_state {
280            Some(id) => id,
281            None => return Vec::new(),
282        };
283
284        let mut visited: AHashMap<&str, bool> = AHashMap::new();
285        let mut queue = vec![initial.as_str()];
286
287        while let Some(state_id) = queue.pop() {
288            if visited.contains_key(state_id) {
289                continue;
290            }
291            visited.insert(state_id, true);
292
293            for t in &self.transitions {
294                if t.from == state_id && !visited.contains_key(t.to.as_str()) {
295                    queue.push(&t.to);
296                }
297            }
298        }
299
300        self.states
301            .iter()
302            .filter(|s| visited.contains_key(s.id.as_str()))
303            .collect()
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_role_is_landmark() {
313        assert!(SemanticRole::Navigation.is_landmark());
314        assert!(SemanticRole::Main.is_landmark());
315        assert!(!SemanticRole::Button.is_landmark());
316    }
317
318    #[test]
319    fn test_role_is_interactable() {
320        assert!(SemanticRole::Button.is_interactable());
321        assert!(SemanticRole::Link.is_interactable());
322        assert!(!SemanticRole::Navigation.is_interactable());
323    }
324
325    #[test]
326    fn test_state_graph_deterministic() {
327        let mut graph = StateGraph::new();
328        graph.states.push(State {
329            id: "home".to_string(),
330            name: "Home".to_string(),
331            description: None,
332            url_pattern: Some("/".to_string()),
333            is_initial: true,
334            is_terminal: false,
335        });
336        graph.transitions.push(Transition {
337            from: "home".to_string(),
338            to: "about".to_string(),
339            trigger: "about-link".to_string(),
340            action: None,
341            guard: None,
342        });
343        graph.initial_state = Some("home".to_string());
344
345        assert!(graph.is_deterministic());
346
347        // Add duplicate transition
348        graph.transitions.push(Transition {
349            from: "home".to_string(),
350            to: "contact".to_string(),
351            trigger: "about-link".to_string(), // Same trigger!
352            action: None,
353            guard: None,
354        });
355
356        assert!(!graph.is_deterministic());
357    }
358}