Skip to main content

ui_storybook/
exporter.rs

1//! Headless story renderer (DEV-07 / AUT-150).
2//!
3//! Hosts the actual HTML emission for stories + the index cockpit.
4//! Both the one-shot `ui-export-stories` binary and the long-lived
5//! `render-worker` binary call into this module — the former so a
6//! single `cargo run` produces the full asset set, the latter so a
7//! warm process can re-render targeted story ids without re-linking
8//! between iterations of the dev loop.
9
10use 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
19/// Export every shipped story plus `style.css` plus `index.html` into
20/// `out_dir`. Creates the directory if missing. Returns the number of
21/// stories written.
22///
23/// # Errors
24/// Returns an `io::Error` if any file write fails.
25pub 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
36/// Re-export the subset of stories identified by `ids`. Empty `ids`
37/// means "re-export everything plus the index". The shared
38/// `style.css` is rewritten on every call so a CSS edit picked up by
39/// the worker still propagates.
40///
41/// Returns a vector of `(id, elapsed_ms)` tuples for everything
42/// rendered, in the order rendered.
43///
44/// # Errors
45/// Returns an `io::Error` if any file write fails or a story id is
46/// unknown to the registry.
47pub 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/// How many stories are currently registered (for `WorkerReply::Hello`).
80#[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("&amp;"),
216            '<' => out.push_str("&lt;"),
217            '>' => out.push_str("&gt;"),
218            '"' => out.push_str("&quot;"),
219            '\'' => out.push_str("&#39;"),
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&amp;b");
234        assert_eq!(escape_html("<x>"), "&lt;x&gt;");
235        assert_eq!(escape_html("a\"b"), "a&quot;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        // At least one story file should exist (use the first registered).
246        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        // Seed CSS so the dir is in a known state.
255        export_all(tmp.path()).unwrap();
256        // Remove a story file, ensure export_subset rewrites it.
257        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}