Skip to main content

ravenrs_project/
lib.rs

1//! Turns code written in the RavenRS playground into a Cargo project that runs on the desktop
2//! with `cargo run` and in the browser, as files or as one `.zip`.
3//!
4//! The code is kept as it was written. The project adds what a program needs around it: the
5//! crates it uses, at the version this crate was published with, a `main` that opens a window,
6//! the browser's entry point and its page.
7//!
8//! ```
9//! use ravenrs_project::Project;
10//!
11//! let project = Project::new("Hello world")
12//!     .source("use ravenrs::prelude::*;\n\nfn hello() -> impl View {\n    Text::new(\"Hello\")\n}\n");
13//! assert_eq!(project.crate_name(), "hello-world");
14//! let lib = project.files()?.into_iter().find(|file| file.path == "src/lib.rs").unwrap();
15//! assert!(lib.contents.contains("pub fn app() -> impl ravenrs::widgets::View {\n    hello()\n}"));
16//! # Ok::<(), String>(())
17//! ```
18
19use std::io::{Cursor, Write};
20use std::path::PathBuf;
21
22/// The version of the RavenRS crates a project depends on: the one this crate was published with.
23pub const RAVENRS_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25/// The `wasm-bindgen` the RavenRS crates were built with. The command line that makes the web
26/// build has to be this same version, so it is pinned.
27pub const WASM_BINDGEN_VERSION: &str = "0.2.130";
28
29/// A RavenRS crate a project uses, with the features it turns on.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct Dependency {
32    /// Its name on crates.io, such as `ravenrs-app`.
33    pub name: String,
34    /// The features it turns on.
35    pub features: Vec<String>,
36}
37
38/// One file of a project.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct ProjectFile {
41    /// Where it goes, with `/` between folders.
42    pub path: String,
43    /// What it holds.
44    pub contents: String,
45}
46
47/// A Cargo project made from code of the playground.
48#[derive(Clone, Debug)]
49pub struct Project {
50    title: String,
51    source: String,
52    helpers: Vec<String>,
53    dependencies: Vec<Dependency>,
54    records: Option<String>,
55    local: Option<PathBuf>,
56}
57
58impl Project {
59    /// A project with this title, which names the window, the page and, in lower case, the crate.
60    pub fn new(title: impl Into<String>) -> Self {
61        Self {
62            title: title.into(),
63            source: String::new(),
64            helpers: Vec::new(),
65            dependencies: vec![Dependency { name: "ravenrs".into(), features: Vec::new() }],
66            records: None,
67            local: None,
68        }
69    }
70
71    /// The code, as written. Its first function is what the window and the page show.
72    pub fn source(mut self, source: impl Into<String>) -> Self {
73        self.source = source.into();
74        self
75    }
76
77    /// Code the source calls that the playground gave it, such as a helper of its examples. It
78    /// goes after the source.
79    pub fn helper(mut self, code: impl Into<String>) -> Self {
80        self.helpers.push(code.into());
81        self
82    }
83
84    /// Uses another RavenRS crate, or turns features on in one already used.
85    pub fn dependency(mut self, name: impl Into<String>, features: &[&str]) -> Self {
86        let name = name.into();
87        let features = features.iter().map(|feature| (*feature).to_owned());
88        match self.dependencies.iter_mut().find(|dependency| dependency.name == name) {
89            Some(dependency) => {
90                for feature in features {
91                    if !dependency.features.contains(&feature) {
92                        dependency.features.push(feature);
93                    }
94                }
95            }
96            None => self.dependencies.push(Dependency { name, features: features.collect() }),
97        }
98        self
99    }
100
101    /// The records the app had, as `MemoryDatabase::export_json` wrote them. They go in
102    /// `src/seed.json`, which `SEED` holds.
103    pub fn records(mut self, json: impl Into<String>) -> Self {
104        self.records = Some(json.into());
105        self
106    }
107
108    /// Takes the RavenRS crates from a checkout of the repository instead of crates.io, by
109    /// `crates/<name>` under `root`: for checking projects against code not published yet.
110    pub fn local_crates(mut self, root: impl Into<PathBuf>) -> Self {
111        self.local = Some(root.into());
112        self
113    }
114
115    /// The name of the crate: the title in lower case, with `-` for what is not a letter or a
116    /// digit.
117    pub fn crate_name(&self) -> String {
118        let mut name = String::new();
119        for c in self.title.chars().flat_map(char::to_lowercase) {
120            if c.is_ascii_alphanumeric() {
121                name.push(c);
122            } else if !name.is_empty() && !name.ends_with('-') {
123                name.push('-');
124            }
125        }
126        let name = name.trim_end_matches('-').to_owned();
127        match name.chars().next() {
128            Some(first) if first.is_ascii_alphabetic() => name,
129            Some(_) => format!("app-{name}"),
130            None => "app".to_owned(),
131        }
132    }
133
134    /// The function the app starts from: the first one of the source.
135    pub fn entry(&self) -> Result<String, String> {
136        let file = syn::parse_file(&self.source).map_err(|e| {
137            let at = e.span().start();
138            format!("line {}, column {}: {e}", at.line, at.column + 1)
139        })?;
140        file.items
141            .iter()
142            .find_map(|item| match item {
143                syn::Item::Fn(function) => Some(function.sig.ident.to_string()),
144                _ => None,
145            })
146            .ok_or_else(|| {
147                "there is no function to start from: write `fn example() -> impl View { .. }`".into()
148            })
149    }
150
151    /// Every file of the project.
152    pub fn files(&self) -> Result<Vec<ProjectFile>, String> {
153        let entry = self.entry()?;
154        let file = |path: &str, contents: String| ProjectFile { path: path.to_owned(), contents };
155        let mut files = vec![
156            file("Cargo.toml", self.manifest()),
157            file("src/lib.rs", self.lib(&entry)),
158            file("src/main.rs", self.main()),
159            file("web/index.html", self.page()),
160            file("README.md", self.readme()),
161            file(".gitignore", "/target\n/web/pkg\n".into()),
162        ];
163        if let Some(records) = &self.records {
164            files.push(file("src/seed.json", records.clone()));
165        }
166        Ok(files)
167    }
168
169    /// Project scaffolding for a complete app source, shared with `cargo ravenrs new`.
170    /// The source already exposes `app()`; its browser entry point stays unchanged.
171    pub fn files_from_app(&self) -> Vec<ProjectFile> {
172        let file = |path: &str, contents: String| ProjectFile { path: path.into(), contents };
173        vec![
174            file("Cargo.toml", self.manifest()),
175            file("src/lib.rs", self.source.clone()),
176            file("src/main.rs", self.main()),
177            file("web/index.html", self.page()),
178            file("README.md", self.readme()),
179            file(".gitignore", "/target\n/web/pkg\n*.sqlite\n*.sqlite-*\n".into()),
180        ]
181    }
182
183    /// Every file of the project in one `.zip`, in a folder named after the crate.
184    pub fn zip(&self) -> Result<Vec<u8>, String> {
185        let folder = self.crate_name();
186        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
187        let options = zip::write::SimpleFileOptions::default()
188            .compression_method(zip::CompressionMethod::Deflated)
189            // There is no clock to read in the browser, and a fixed time keeps the file the same.
190            .last_modified_time(zip::DateTime::default());
191        for file in self.files()? {
192            zip.start_file(format!("{folder}/{}", file.path), options).map_err(|e| e.to_string())?;
193            zip.write_all(file.contents.as_bytes()).map_err(|e| e.to_string())?;
194        }
195        zip.finish().map(Cursor::into_inner).map_err(|e| e.to_string())
196    }
197
198    fn requirement(&self, dependency: &Dependency) -> String {
199        let place = match &self.local {
200            Some(root) => {
201                let path = root.join("crates").join(&dependency.name);
202                format!("path = {:?}", path.to_string_lossy())
203            }
204            None => format!("version = \"{RAVENRS_VERSION}\""),
205        };
206        if dependency.features.is_empty() {
207            format!("{} = {{ {place} }}", dependency.name)
208        } else {
209            let features: Vec<String> = dependency.features.iter().map(|f| format!("{f:?}")).collect();
210            format!("{} = {{ {place}, features = [{}] }}", dependency.name, features.join(", "))
211        }
212    }
213
214    fn manifest(&self) -> String {
215        let dependencies: Vec<String> = self.dependencies.iter().map(|d| self.requirement(d)).collect();
216        format!(
217            r#"[package]
218name = "{name}"
219version = "0.1.0"
220edition = "2024"
221publish = false
222
223[lib]
224crate-type = ["cdylib", "rlib"]
225
226[dependencies]
227{dependencies}
228
229[target.'cfg(target_arch = "wasm32")'.dependencies]
230wasm-bindgen = "={WASM_BINDGEN_VERSION}"
231wasm-bindgen-futures = "0.4"
232console_error_panic_hook = "0.1"
233web-sys = {{ version = "0.3", features = ["console"] }}
234
235[profile.release]
236opt-level = "s"
237lto = true
238"#,
239            name = self.crate_name(),
240            dependencies = dependencies.join("\n"),
241        )
242    }
243
244    fn lib(&self, entry: &str) -> String {
245        let mut lib = format!(
246            "//! {title}, made in the RavenRS playground.\n#![allow(unused_imports, dead_code)]\n\n{source}\n",
247            title = self.title,
248            source = self.source.trim_end(),
249        );
250        for helper in &self.helpers {
251            lib.push('\n');
252            lib.push_str(helper.trim_end());
253            lib.push('\n');
254        }
255        lib.push_str(&format!(
256            "\n/// What the window and the page show.\npub fn app() -> impl ravenrs::widgets::View {{\n    {entry}()\n}}\n"
257        ));
258        if self.records.is_some() {
259            lib.push_str(
260                "\n/// The records the app had in the playground, as `MemoryDatabase::export_json` wrote them:\n\
261                 /// `MemoryDatabase::import_json(SEED)` puts them back.\n\
262                 pub const SEED: &str = include_str!(\"seed.json\");\n",
263            );
264        }
265        lib.push_str(WEB_ENTRY);
266        lib
267    }
268
269    fn main(&self) -> String {
270        format!(
271            r#"//! The desktop: `cargo run`.
272
273#[cfg(not(target_arch = "wasm32"))]
274fn main() -> Result<(), ravenrs::native::PlatformError> {{
275    let options = ravenrs::native::WindowOptions::new({title:?}).size(1000.0, 700.0);
276    ravenrs::native::run({krate}::app(), options)
277}}
278
279// The browser starts from `lib.rs`.
280#[cfg(target_arch = "wasm32")]
281fn main() {{}}
282"#,
283            title = self.title,
284            krate = self.crate_name().replace('-', "_"),
285        )
286    }
287
288    fn page(&self) -> String {
289        format!(
290            r#"<!doctype html>
291<html lang="en">
292  <head>
293    <meta charset="utf-8" />
294    <meta name="viewport" content="width=device-width, initial-scale=1" />
295    <title>{title}</title>
296    <style>
297      html, body {{ height: 100%; margin: 0; overflow: hidden; background: #0b0d12; }}
298      #raven-canvas {{ display: block; width: 100vw; height: 100vh; }}
299    </style>
300  </head>
301  <body>
302    <canvas id="raven-canvas" aria-label="{title}"></canvas>
303    <script type="module">
304      import init from "./pkg/{krate}.js";
305      init();
306    </script>
307  </body>
308</html>
309"#,
310            title = escape_html(&self.title),
311            krate = self.crate_name().replace('-', "_"),
312        )
313    }
314
315    fn readme(&self) -> String {
316        let krate = self.crate_name().replace('-', "_");
317        format!(
318            r"# {title}
319
320Made in the RavenRS playground. The code is in `src/lib.rs`.
321
322## On the desktop
323
324```sh
325cargo run
326```
327
328## In the browser
329
330```sh
331rustup target add wasm32-unknown-unknown
332cargo install wasm-bindgen-cli --version {WASM_BINDGEN_VERSION}
333cargo build --release --lib --target wasm32-unknown-unknown
334wasm-bindgen --target web --out-dir web/pkg target/wasm32-unknown-unknown/release/{krate}.wasm
335python3 -m http.server -d web 8080
336```
337
338Then open http://localhost:8080.
339",
340            title = self.title,
341        )
342    }
343}
344
345const WEB_ENTRY: &str = r#"
346#[cfg(target_arch = "wasm32")]
347mod web_entry {
348    use std::cell::RefCell;
349
350    use wasm_bindgen::prelude::*;
351
352    thread_local! {
353        static APP: RefCell<Option<ravenrs::web::WebApp>> = const { RefCell::new(None) };
354    }
355
356    /// Runs when the page loads the module.
357    #[wasm_bindgen(start)]
358    pub fn start() {
359        console_error_panic_hook::set_once();
360        wasm_bindgen_futures::spawn_local(async {
361            match ravenrs::web::start(super::app(), ravenrs::web::WebOptions::new("raven-canvas")).await {
362                Ok(app) => APP.with(|kept| *kept.borrow_mut() = Some(app)),
363                Err(e) => web_sys::console::error_1(&e.to_string().into()),
364            }
365        });
366    }
367}
368"#;
369
370fn escape_html(text: &str) -> String {
371    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
372}