Skip to main content

dioxus_showcase_core/
runtime.rs

1use crate::manifest::{StoryDefinition, StoryManifest};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct StoryEntry {
5    pub definition: StoryDefinition,
6    pub renderer_symbol: &'static str,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ProviderDefinition {
11    pub source_path: String,
12    pub module_path: String,
13    pub wrap_symbol: String,
14    pub index: i32,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct StoryNavigationNode {
19    pub segment: String,
20    pub title_path: String,
21    pub story_id: Option<String>,
22    pub children: Vec<Self>,
23}
24
25pub trait StoryTreeEntry {
26    /// Returns the route id for this story-like entry.
27    fn story_id(&self) -> &str;
28    /// Returns the slash-delimited title path for this story-like entry.
29    fn story_title(&self) -> &str;
30}
31
32#[derive(Debug, Default)]
33pub struct ShowcaseRegistry {
34    stories: Vec<StoryEntry>,
35}
36
37impl ShowcaseRegistry {
38    /// Registers one story entry with the in-memory registry.
39    pub fn register(&mut self, entry: StoryEntry) {
40        self.stories.push(entry);
41    }
42
43    /// Converts the registered stories into a serializable manifest.
44    pub fn manifest(&self) -> StoryManifest {
45        let mut manifest = StoryManifest::new(1);
46        for story in &self.stories {
47            manifest.add_story(story.definition.clone());
48        }
49        manifest
50    }
51
52    /// Returns the number of registered stories.
53    pub fn story_count(&self) -> usize {
54        self.stories.len()
55    }
56}
57
58/// Builds a nested navigation tree from slash-delimited story titles.
59pub fn build_story_navigation<T: StoryTreeEntry>(stories: &[T]) -> Vec<StoryNavigationNode> {
60    let mut nodes = Vec::new();
61
62    for story in stories {
63        let title = story.story_title().trim();
64        let segments = split_story_title(title);
65        if segments.is_empty() {
66            continue;
67        }
68
69        insert_story_node(&mut nodes, &segments, story.story_id());
70    }
71
72    nodes
73}
74
75/// Splits a story title into non-empty trimmed path segments.
76fn split_story_title(title: &str) -> Vec<&str> {
77    title.split('/').map(str::trim).filter(|segment| !segment.is_empty()).collect()
78}
79
80/// Inserts a top-level story node into the navigation tree.
81fn insert_story_node(nodes: &mut Vec<StoryNavigationNode>, segments: &[&str], story_id: &str) {
82    let segment = segments[0];
83    let node_index = match nodes.iter().position(|node| node.segment == segment) {
84        Some(index) => index,
85        None => {
86            let title_path = segment.to_owned();
87            nodes.push(StoryNavigationNode {
88                segment: segment.to_owned(),
89                title_path,
90                story_id: None,
91                children: Vec::new(),
92            });
93            nodes.len() - 1
94        }
95    };
96
97    let node = &mut nodes[node_index];
98    if segments.len() == 1 {
99        node.story_id = Some(story_id.to_owned());
100        return;
101    }
102
103    insert_story_child(&mut node.children, &node.title_path, &segments[1..], story_id);
104}
105
106/// Inserts nested child segments below an existing navigation node.
107fn insert_story_child(
108    children: &mut Vec<StoryNavigationNode>,
109    parent_path: &str,
110    segments: &[&str],
111    story_id: &str,
112) {
113    let segment = segments[0];
114    let node_index = match children.iter().position(|node| node.segment == segment) {
115        Some(index) => index,
116        None => {
117            let title_path = format!("{parent_path}/{segment}");
118            children.push(StoryNavigationNode {
119                segment: segment.to_owned(),
120                title_path,
121                story_id: None,
122                children: Vec::new(),
123            });
124            children.len() - 1
125        }
126    };
127
128    let node = &mut children[node_index];
129    if segments.len() == 1 {
130        node.story_id = Some(story_id.to_owned());
131        return;
132    }
133
134    insert_story_child(&mut node.children, &node.title_path, &segments[1..], story_id);
135}
136
137impl StoryTreeEntry for StoryEntry {
138    /// Returns the id stored in the entry definition.
139    fn story_id(&self) -> &str {
140        &self.definition.id
141    }
142
143    /// Returns the title stored in the entry definition.
144    fn story_title(&self) -> &str {
145        &self.definition.title
146    }
147}
148
149impl<T> StoryTreeEntry for &T
150where
151    T: StoryTreeEntry + ?Sized,
152{
153    /// Forwards the story id lookup through a shared reference.
154    fn story_id(&self) -> &str {
155        (*self).story_id()
156    }
157
158    /// Forwards the story title lookup through a shared reference.
159    fn story_title(&self) -> &str {
160        (*self).story_title()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn register_updates_count_and_manifest() {
170        let mut registry = ShowcaseRegistry::default();
171        registry.register(StoryEntry {
172            definition: StoryDefinition {
173                id: "atoms-button-default".to_owned(),
174                title: "Atoms/Button/Default".to_owned(),
175                source_path: "showcase/button.stories.rs".to_owned(),
176                module_path: "showcase::button_default".to_owned(),
177                renderer_symbol: "button_default".to_owned(),
178                tags: vec!["atoms".to_owned()],
179            },
180            renderer_symbol: "button_default",
181        });
182
183        assert_eq!(registry.story_count(), 1);
184        let manifest = registry.manifest();
185        assert_eq!(manifest.schema_version, 1);
186        assert_eq!(manifest.stories.len(), 1);
187        assert_eq!(manifest.stories[0].id, "atoms-button-default");
188    }
189
190    #[test]
191    fn build_story_navigation_groups_titles_into_tree() {
192        let stories = vec![
193            StoryEntry {
194                definition: StoryDefinition {
195                    id: "atoms-dropdown-link".to_owned(),
196                    title: "Atoms/Dropdown Link".to_owned(),
197                    source_path: "showcase/button.stories.rs".to_owned(),
198                    module_path: "showcase::dropdown_link".to_owned(),
199                    renderer_symbol: "dropdown_link".to_owned(),
200                    tags: vec![],
201                },
202                renderer_symbol: "dropdown_link",
203            },
204            StoryEntry {
205                definition: StoryDefinition {
206                    id: "atoms-button".to_owned(),
207                    title: "Atoms/Button".to_owned(),
208                    source_path: "showcase/button.stories.rs".to_owned(),
209                    module_path: "showcase::button".to_owned(),
210                    renderer_symbol: "button".to_owned(),
211                    tags: vec![],
212                },
213                renderer_symbol: "button",
214            },
215        ];
216
217        let navigation = build_story_navigation(&stories);
218        assert_eq!(navigation.len(), 1);
219        assert_eq!(navigation[0].segment, "Atoms");
220        assert_eq!(navigation[0].title_path, "Atoms");
221        assert_eq!(navigation[0].children.len(), 2);
222        assert_eq!(navigation[0].children[0].segment, "Dropdown Link");
223        assert_eq!(navigation[0].children[0].story_id.as_deref(), Some("atoms-dropdown-link"));
224        assert_eq!(navigation[0].children[1].segment, "Button");
225        assert_eq!(navigation[0].children[1].story_id.as_deref(), Some("atoms-button"));
226    }
227
228    #[test]
229    fn build_story_navigation_allows_branch_to_be_story_and_parent() {
230        let stories = vec![
231            StoryEntry {
232                definition: StoryDefinition {
233                    id: "atoms".to_owned(),
234                    title: "Atoms".to_owned(),
235                    source_path: "showcase/atoms.rs".to_owned(),
236                    module_path: "showcase::atoms".to_owned(),
237                    renderer_symbol: "atoms".to_owned(),
238                    tags: vec![],
239                },
240                renderer_symbol: "atoms",
241            },
242            StoryEntry {
243                definition: StoryDefinition {
244                    id: "atoms-button".to_owned(),
245                    title: "Atoms/Button".to_owned(),
246                    source_path: "showcase/button.rs".to_owned(),
247                    module_path: "showcase::button".to_owned(),
248                    renderer_symbol: "button".to_owned(),
249                    tags: vec![],
250                },
251                renderer_symbol: "button",
252            },
253        ];
254
255        let navigation = build_story_navigation(&stories);
256        assert_eq!(navigation.len(), 1);
257        assert_eq!(navigation[0].story_id.as_deref(), Some("atoms"));
258        assert_eq!(navigation[0].children.len(), 1);
259        assert_eq!(navigation[0].children[0].segment, "Button");
260    }
261
262    #[test]
263    fn build_story_navigation_accepts_slices_of_references() {
264        let stories = [StoryEntry {
265            definition: StoryDefinition {
266                id: "atoms-button".to_owned(),
267                title: "Atoms/Button".to_owned(),
268                source_path: "showcase/button.rs".to_owned(),
269                module_path: "showcase::button".to_owned(),
270                renderer_symbol: "button".to_owned(),
271                tags: vec![],
272            },
273            renderer_symbol: "button",
274        }];
275        let filtered = stories.iter().collect::<Vec<_>>();
276
277        let navigation = build_story_navigation(&filtered);
278
279        assert_eq!(navigation.len(), 1);
280        assert_eq!(navigation[0].segment, "Atoms");
281        assert_eq!(navigation[0].children.len(), 1);
282        assert_eq!(navigation[0].children[0].story_id.as_deref(), Some("atoms-button"));
283    }
284}