1use std::collections::BTreeMap;
11use std::path::Path;
12use std::time::Instant;
13
14use crate::stories::{Story, all_stories};
15
16const STYLE: &str = include_str!("../assets/style.css");
17const INDEX_SCRIPT: &str = include_str!("bin/index_script.js");
18
19pub fn export_all(out_dir: &Path) -> std::io::Result<usize> {
26 std::fs::create_dir_all(out_dir)?;
27 std::fs::write(out_dir.join("style.css"), STYLE)?;
28 let stories = all_stories();
29 for s in &stories {
30 write_story_html(s, out_dir)?;
31 }
32 std::fs::write(out_dir.join("index.html"), render_index(&stories))?;
33 Ok(stories.len())
34}
35
36pub fn export_subset(out_dir: &Path, ids: &[String]) -> std::io::Result<Vec<(String, u64)>> {
48 std::fs::create_dir_all(out_dir)?;
49 std::fs::write(out_dir.join("style.css"), STYLE)?;
50 let stories = all_stories();
51 if ids.is_empty() {
52 let mut out = Vec::with_capacity(stories.len());
53 for s in &stories {
54 let start = Instant::now();
55 write_story_html(s, out_dir)?;
56 let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
57 out.push((s.id.to_owned(), elapsed_ms));
58 }
59 std::fs::write(out_dir.join("index.html"), render_index(&stories))?;
60 return Ok(out);
61 }
62 let by_id: BTreeMap<&str, &Story> = stories.iter().map(|s| (s.id, s)).collect();
63 let mut out = Vec::with_capacity(ids.len());
64 for id in ids {
65 let s = by_id.get(id.as_str()).ok_or_else(|| {
66 std::io::Error::new(
67 std::io::ErrorKind::NotFound,
68 format!("unknown story id: {id}"),
69 )
70 })?;
71 let start = Instant::now();
72 write_story_html(s, out_dir)?;
73 let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
74 out.push((s.id.to_owned(), elapsed_ms));
75 }
76 Ok(out)
77}
78
79#[must_use]
81pub fn story_count() -> usize {
82 all_stories().len()
83}
84
85fn write_story_html(story: &Story, out_dir: &Path) -> std::io::Result<()> {
86 let body = (story.render)();
87 let html = format!(
88 r#"<!doctype html>
89<html lang="en">
90 <head>
91 <meta charset="utf-8" />
92 <title>{title}</title>
93 <link rel="stylesheet" href="./style.css" />
94 <style>
95 body {{ padding: 24px; }}
96 </style>
97 </head>
98 <body>
99 {body}
100 </body>
101</html>
102"#,
103 title = escape_html(story.title),
104 body = body,
105 );
106 let path = out_dir.join(format!("{}.html", story.id));
107 std::fs::write(path, html)
108}
109
110fn render_index(stories: &[Story]) -> String {
111 let mut grouped: BTreeMap<&'static str, Vec<&Story>> = BTreeMap::new();
112 for s in stories {
113 grouped.entry(s.category).or_default().push(s);
114 }
115 for v in grouped.values_mut() {
116 v.sort_by_key(|s| s.id);
117 }
118
119 let mut sidebar = String::new();
120 for (category, items) in &grouped {
121 let cat = escape_html(category);
122 let cat_lc = escape_html(&category.to_ascii_lowercase());
123 sidebar.push_str(" <li class=\"storybook-index-category\" data-category=\"");
124 sidebar.push_str(&cat_lc);
125 sidebar.push_str("\">\n <span class=\"storybook-index-heading\">");
126 sidebar.push_str(&cat);
127 sidebar.push_str("</span>\n <ul>\n");
128 for s in items {
129 let id = escape_html(s.id);
130 let title = escape_html(s.title);
131 let title_lc = escape_html(&s.title.to_ascii_lowercase());
132 sidebar.push_str(" <li class=\"storybook-index-row\" data-id=\"");
133 sidebar.push_str(&id);
134 sidebar.push_str("\" data-title=\"");
135 sidebar.push_str(&title_lc);
136 sidebar.push_str("\" data-category=\"");
137 sidebar.push_str(&cat_lc);
138 sidebar.push_str("\"><a href=\"#");
139 sidebar.push_str(&id);
140 sidebar.push_str("\" data-id=\"");
141 sidebar.push_str(&id);
142 sidebar.push_str("\">");
143 sidebar.push_str(&title);
144 sidebar.push_str("</a></li>\n");
145 }
146 sidebar.push_str(" </ul>\n </li>\n");
147 }
148
149 let first_id = stories.first().map_or("", |s| s.id);
150 let count = stories.len();
151
152 format!(
153 r#"<!doctype html>
154<html lang="en">
155 <head>
156 <meta charset="utf-8" />
157 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
158 <title>ui-storybook · {count} stories</title>
159 <link rel="stylesheet" href="./style.css" />
160 <style>
161 body {{ margin: 0; height: 100vh; overflow: hidden; }}
162 </style>
163 </head>
164 <body data-first-story="{first}">
165 <div class="storybook-index-root">
166 <aside class="storybook-index-sidebar">
167 <div class="storybook-index-topbar">
168 <span class="storybook-index-title">ui-storybook</span>
169 <span class="storybook-index-count">{count}</span>
170 </div>
171 <div class="storybook-index-search">
172 <input
173 id="storybook-index-filter"
174 type="search"
175 placeholder="Filter (press /)"
176 autocomplete="off"
177 spellcheck="false"
178 />
179 </div>
180 <nav>
181 <ul class="storybook-index-list">
182{sidebar} </ul>
183 </nav>
184 </aside>
185 <main class="storybook-index-main">
186 <header class="storybook-index-header">
187 <span class="storybook-index-current" id="storybook-index-current">{first}</span>
188 <a class="storybook-index-open" id="storybook-index-open" href="./{first}.html" target="_blank" rel="noopener">Open in new tab ↗</a>
189 </header>
190 <iframe
191 class="storybook-index-frame"
192 id="storybook-index-frame"
193 title="story preview"
194 src="./{first}.html"
195 ></iframe>
196 </main>
197 </div>
198 <script>
199{script}
200 </script>
201 </body>
202</html>
203"#,
204 count = count,
205 first = escape_html(first_id),
206 sidebar = sidebar,
207 script = INDEX_SCRIPT,
208 )
209}
210
211fn escape_html(s: &str) -> String {
212 let mut out = String::with_capacity(s.len());
213 for c in s.chars() {
214 match c {
215 '&' => out.push_str("&"),
216 '<' => out.push_str("<"),
217 '>' => out.push_str(">"),
218 '"' => out.push_str("""),
219 '\'' => out.push_str("'"),
220 other => out.push(other),
221 }
222 }
223 out
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use tempfile::TempDir;
230
231 #[test]
232 fn escape_html_handles_special_chars() {
233 assert_eq!(escape_html("a&b"), "a&b");
234 assert_eq!(escape_html("<x>"), "<x>");
235 assert_eq!(escape_html("a\"b"), "a"b");
236 }
237
238 #[test]
239 fn export_all_writes_every_story_plus_index_plus_css() {
240 let tmp = TempDir::new().unwrap();
241 let count = export_all(tmp.path()).unwrap();
242 assert!(count > 0);
243 assert!(tmp.path().join("style.css").exists());
244 assert!(tmp.path().join("index.html").exists());
245 let stories = all_stories();
247 let first = stories.first().expect("at least one story");
248 assert!(tmp.path().join(format!("{}.html", first.id)).exists());
249 }
250
251 #[test]
252 fn export_subset_writes_only_named_stories() {
253 let tmp = TempDir::new().unwrap();
254 export_all(tmp.path()).unwrap();
256 let stories = all_stories();
258 let first = stories.first().expect("at least one story");
259 let p = tmp.path().join(format!("{}.html", first.id));
260 std::fs::remove_file(&p).unwrap();
261 let written = export_subset(tmp.path(), &[first.id.to_owned()]).unwrap();
262 assert_eq!(written.len(), 1);
263 assert_eq!(written[0].0, first.id);
264 assert!(p.exists());
265 }
266
267 #[test]
268 fn export_subset_empty_renders_all() {
269 let tmp = TempDir::new().unwrap();
270 let written = export_subset(tmp.path(), &[]).unwrap();
271 assert_eq!(written.len(), all_stories().len());
272 assert!(tmp.path().join("index.html").exists());
273 }
274
275 #[test]
276 fn export_subset_unknown_id_is_an_error() {
277 let tmp = TempDir::new().unwrap();
278 let err = export_subset(tmp.path(), &["nope-not-a-real-story".into()]).unwrap_err();
279 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
280 }
281
282 #[test]
283 fn story_count_matches_all_stories() {
284 assert_eq!(story_count(), all_stories().len());
285 }
286}