1use std::io::{Cursor, Write};
20use std::path::PathBuf;
21
22pub const RAVENRS_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25pub const WASM_BINDGEN_VERSION: &str = "0.2.130";
28
29#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct Dependency {
32 pub name: String,
34 pub features: Vec<String>,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct ProjectFile {
41 pub path: String,
43 pub contents: String,
45}
46
47#[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 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 pub fn source(mut self, source: impl Into<String>) -> Self {
73 self.source = source.into();
74 self
75 }
76
77 pub fn helper(mut self, code: impl Into<String>) -> Self {
80 self.helpers.push(code.into());
81 self
82 }
83
84 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 pub fn records(mut self, json: impl Into<String>) -> Self {
104 self.records = Some(json.into());
105 self
106 }
107
108 pub fn local_crates(mut self, root: impl Into<PathBuf>) -> Self {
111 self.local = Some(root.into());
112 self
113 }
114
115 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 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 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 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 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 .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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
372}