diff --git a/README.md b/README.md index 29504a5..9b67078 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil

Prototype and hand off

- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. + Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 1938766..4634063 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50; /// The languages a component can be connected in. pub fn language(s: &str) -> Option { - CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose)) + CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -65,6 +65,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { match lang { _ if path.is_empty() => String::new(), CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), + CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()), _ => format!("import {path}"), } } @@ -172,6 +173,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang)); match (swapped, lang) { (Some(l), CodeLang::React) => format!("<{} />", l.name), + (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), @@ -191,9 +193,10 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], Piece::Value { name, inner } => match find(c, values, name) { Some((p, v)) => { let e = expr(doc, c, p, v, lang); - // JSX: a plain string reads best as an attribute value, anything else is an - // expression in braces (JSX attribute strings have no escapes). - if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) { + // JSX and `view!`: a plain string reads best as an attribute value, anything + // else is an expression in braces (JSX attribute strings have no escapes). + let markup = matches!(lang, CodeLang::React | CodeLang::Leptos); + if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) { out.push_str(&e); } else { out.push_str(&format!("{{{e}}}")); @@ -215,6 +218,9 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect(); match lang { CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::()), + CodeLang::Leptos => { + format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) + } CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), } @@ -234,11 +240,13 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res let path = &link.import_path; let path_ok = match lang { CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), + CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'), _ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'), }; if path.chars().count() > MAX_IMPORT || !path_ok { return Err(match lang { CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), + CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"), _ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"), }); } @@ -274,7 +282,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> { /// The `language` parameter (default React). fn lang_param(cmd: &str, p: &Value) -> Result { match text(cmd, p, "language")? { - Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|swiftui|compose")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")), None => Ok(CodeLang::React), } } @@ -304,7 +312,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", true, |s, p| { let cmd = "codeConnect.set"; @@ -419,7 +427,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let (Some(component), Some(lang), Some(name), Some(template)) = (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) else { - warnings.push(format!("link {}: needs component, language (react|swiftui|compose), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1)); continue; }; let link = CodeLink { diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index d505fc8..9d657d5 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -65,6 +65,15 @@ fn connected_instances_show_the_real_snippet() { ); assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n"); assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated"); + // Leptos: a `use` line and `view!` markup (strings with escapes in braces). + ex( + &mut s, + "codeConnect.set", + json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}), + ); + assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n"); + assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path"); + ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); // inspect.componentCode: the snippet as the usage, and the link. let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"})); assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n"); diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 3f6c42b..513bf50 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -18,13 +18,13 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|swiftui|compose (default css; html and react are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML and React; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -42,7 +42,7 @@ pub fn specs() -> Vec { let connected = crate::code_connect::link(doc, id, lang).is_some(); let code = code_for(doc, id, lang); let code = match rem { - Some(root) if matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React) => px_to_rem(&code, root), + Some(root) if lang.is_web() => px_to_rem(&code, root), _ => code, }; Ok(json!({"code": code, "language": lang.id(), "connected": connected})) @@ -59,17 +59,20 @@ pub enum CodeLang { Html, /// A React function component (TSX) with its stylesheet. React, + /// A Leptos component (`view!` markup) with its stylesheet. + Leptos, SwiftUi, Compose, } impl CodeLang { - pub const ALL: [CodeLang; 5] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", CodeLang::Html => "html", CodeLang::React => "react", + CodeLang::Leptos => "leptos", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } @@ -79,10 +82,15 @@ impl CodeLang { CodeLang::Css => "CSS", CodeLang::Html => "HTML + CSS", CodeLang::React => "React", + CodeLang::Leptos => "Leptos", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } } + /// Languages whose lengths are CSS pixels (they can be shown in rem). + pub fn is_web(self) -> bool { + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) + } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); @@ -136,7 +144,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), - CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), + CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), } } @@ -561,16 +569,18 @@ fn jsx_text(s: &str) -> String { /// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs. fn html(doc: &Document, id: NodeId) -> String { let ctx = Ctx::new(doc, id); - let mut out = Markup::new(&ctx, false); + let mut out = Markup::new(&ctx, CodeLang::Html); out.node(id, None, 0, ""); format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles) } -/// Markup being built for a layer tree: HTML (`class`) or React JSX (`className`, component -/// parameters, instances as calls), with its stylesheet. +/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!` +/// markup (text as Rust string literals); the last two with component parameters and instances +/// as calls. With its stylesheet. pub(crate) struct Markup<'c, 'a> { ctx: &'c Ctx<'a>, - jsx: bool, + /// Html, React or Leptos. + lang: CodeLang, used: Vec, pub markup: String, pub styles: String, @@ -584,10 +594,10 @@ pub(crate) struct Markup<'c, 'a> { } impl<'c, 'a> Markup<'c, 'a> { - pub fn new(ctx: &'c Ctx<'a>, jsx: bool) -> Self { + pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self { Markup { ctx, - jsx, + lang, used: Vec::new(), markup: String::new(), styles: String::new(), @@ -598,22 +608,34 @@ impl<'c, 'a> Markup<'c, 'a> { } } - /// JSX indents by two spaces, HTML by four. + /// JSX indents by two spaces, HTML and Leptos by four. fn pad(&self, depth: usize) -> String { - if self.jsx { " ".repeat(depth) } else { indent(depth) } + if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) } } - /// A layer, shown only while its boolean parameter is on (JSX). + /// Component code (React or Leptos): parameters and instances as calls. + fn code(&self) -> bool { + self.lang != CodeLang::Html + } + + /// A parameter's name in the generated code. + fn ident(&self, p: &Param) -> String { + if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() } + } + + /// A layer, shown only while its boolean parameter is on (component code). pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) { let Some(n) = self.ctx.doc.node(id) else { return }; - if self.jsx + if self.code() && id != self.ctx.root && let Some(p) = self.ctx.param(n, "visible") { - let pad = self.pad(depth); - self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident)); + let (pad, p) = (self.pad(depth), self.ident(p)); + let (open, close) = + if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") }; + self.markup.push_str(&format!("{pad}{open}\n")); self.element(n, parent, depth + 1, path); - self.markup.push_str(&format!("{pad})}}\n")); + self.markup.push_str(&format!("{pad}{close}\n")); return; } self.element(n, parent, depth, path); @@ -639,7 +661,7 @@ impl<'c, 'a> Markup<'c, 'a> { None => class_name(&n.name, &mut self.used), }; let pad = self.pad(depth); - let attr = if self.jsx { "className" } else { "class" }; + let attr = if self.lang == CodeLang::React { "className" } else { "class" }; // How it sits in its parent: in the flex flow, or placed at its position. let mut decls = String::new(); let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; @@ -663,12 +685,13 @@ impl<'c, 'a> Markup<'c, 'a> { decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); } } - // Nested instances are calls (JSX), in a wrapper that places them when needed. - if self.jsx && !root && matches!(n.kind, NodeKind::Instance { .. }) { + // Nested instances are calls (component code), in a wrapper that places them when needed. + if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) { let at = depth + usize::from(!decls.is_empty()); + let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" }; let inner = match self.ctx.param(n, "mainComponent") { - Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)), - None => self.ctx.call(n, CodeLang::React, at), + Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), + None => self.ctx.call(n, self.lang, at), }; if let Some(inner) = inner { if decls.is_empty() { @@ -692,19 +715,26 @@ impl<'c, 'a> Markup<'c, 'a> { let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>"); match &n.kind { NodeKind::Text(t) => { - let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) { - Some(p) => format!("{{{}}}", p.ident), - None if self.jsx => jsx_text(&t.characters), - None => html_escape(&t.characters), + let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) { + (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)), + (Some(p), _) => format!("{{{}}}", p.ident), + (None, CodeLang::React) => jsx_text(&t.characters), + // `view!` takes text as string literals and void elements self-closed. + (None, CodeLang::Leptos) => { + t.characters.chars().take(10_000).collect::().lines().map(|l| format!("{l:?}")).collect::>().join("
") + } + (None, _) => html_escape(&t.characters), }; self.markup.push_str(&format!("{}{body}

\n", open("p"))); } _ => { let kids = self.ctx.kids(n); - let slot = self.ctx.param(n, "slot").filter(|_| self.jsx); + let slot = self.ctx.param(n, "slot").filter(|_| self.code()); + let leptos = self.lang == CodeLang::Leptos; let open = open("div"); if let Some(p) = slot.filter(|_| kids.is_empty()) { - self.markup.push_str(&format!("{open}{{{}}}\n", p.ident)); + let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() }; + self.markup.push_str(&format!("{open}{{{content}}}\n")); } else if kids.is_empty() || self.ctx.too_deep(depth) { self.markup.push_str(&format!("{open}\n")); } else { @@ -712,6 +742,10 @@ impl<'c, 'a> Markup<'c, 'a> { // A slot shows the instance's content, or else the main's. let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2)); let inner = match slot { + Some(p) if leptos => { + self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p))); + depth + 2 + } Some(p) => { self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident)); depth + 3 @@ -726,8 +760,10 @@ impl<'c, 'a> Markup<'c, 'a> { seen.push(&kn.name); self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name)); } - if slot.is_some() { - self.markup.push_str(&format!("{p2}\n{p1})}}\n")); + match slot { + Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")), + Some(_) => self.markup.push_str(&format!("{p2}\n{p1})}}\n")), + None => {} } self.markup.push_str(&format!("{pad}\n")); } diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 97f147c..c810cf1 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -1,9 +1,9 @@ -//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose -//! component whose properties are its parameters (a variant property → a union or enum, boolean → +//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack +//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean → //! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot → //! optional content), and an instance becomes a call passing the values it sets. Variants of a set -//! share one definition (the set's default variant); in React their differences become CSS rules on -//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component +//! share one definition (the set's default variant); in React and Leptos their differences become CSS +//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component //! connected to code (see [`crate::code_connect`]) shows its real snippet instead. use figcraft_doc::*; @@ -67,14 +67,14 @@ pub fn specs() -> Vec { vec![spec!( "inspect.componentCode", "Component code", - "{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", false, |s, p| { let cmd = "inspect.componentCode"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?; let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) { - Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l, - Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")), + Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l, + Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")), None => CodeLang::React, }; let doc = s.doc()?; @@ -107,9 +107,9 @@ pub fn specs() -> Vec { )] } -/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's -/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a -/// function component). +/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's +/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it +/// in a function component). pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { let Some(n) = doc.node(id) else { return String::new() }; if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { @@ -120,7 +120,10 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { (NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)), (NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang), _ => match lang { - CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }), + CodeLang::React | CodeLang::Leptos => { + let c = Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }; + if lang == CodeLang::Leptos { leptos(doc, &c) } else { react(doc, &c) } + } CodeLang::SwiftUi => { let mut out = String::new(); swift(&Ctx::new(doc, id), id, 0, &mut out, &[]); @@ -358,6 +361,52 @@ fn ident(name: &str, used: &mut Vec) -> String { out } +/// Rust's keywords, and `children` (Leptos passes a component's children by that name). +const RUST_RESERVED: &[&str] = &[ + "abstract", "as", "async", "await", "become", "box", "break", "children", "const", "continue", "crate", "do", "dyn", "else", "enum", "extern", + "false", "final", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", + "ref", "return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", "virtual", "where", + "while", "yield", +]; + +/// A parameter's Rust name: its camelCase name in snake_case (`showIcon` → `show_icon`), with a +/// trailing `_` when Rust reserves it (`type_`; camelCase names have no `_`, so it stays unique). +pub(crate) fn rust_ident(ident: &str) -> String { + let mut out = String::new(); + for (i, ch) in ident.chars().enumerate() { + if ch.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + if RUST_RESERVED.contains(&out.as_str()) { + out.push('_'); + } + out +} + +/// A Rust type name: `Self` is taken. +fn rust_type(t: &str) -> String { + if t == "Self" { "SelfComponent".into() } else { t.to_string() } +} + +/// A Leptos variant enum's name: the component's name and the property's, never the props struct +/// `#[component]` makes (`ButtonProps`). +fn leptos_enum(c: &Component, p: &Param) -> String { + let t = rust_type(&c.type_name); + let name = format!("{t}{}", p.enum_name); + if name == format!("{t}Props") { format!("{name}Variant") } else { name } +} + +/// Rust enum variants for variant values: [`cases`] in PascalCase, `Self` taken. +fn rust_cases(values: &[String]) -> Vec { + cases(values, false).into_iter().map(|c| if c == "Self" { "SelfValue".into() } else { c }).collect() +} + /// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``), /// Kotlin's PascalCase (`Small`). fn cases(values: &[String], swift: bool) -> Vec { @@ -383,6 +432,11 @@ fn cases(values: &[String], swift: bool) -> Vec { .collect() } +/// A Rust string literal. +pub(crate) fn rust_string(s: &str) -> String { + format!("{:?}", s.chars().take(10_000).collect::()) +} + /// A JavaScript string literal. pub(crate) fn js_string(s: &str) -> String { let s: String = s.chars().take(10_000).collect(); @@ -405,6 +459,11 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value match (&p.kind, lang) { (ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(), (ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")), + (ParamKind::Text, CodeLang::Leptos) => rust_string(value.as_str().unwrap_or("")), + (ParamKind::Variant(opts), CodeLang::Leptos) => { + let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0); + format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into())) + } (ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose), (ParamKind::Variant(opts), l) => { let v = value.as_str().unwrap_or(""); @@ -420,6 +479,8 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value let t = id_of(value).and_then(|i| component_type(doc, i)); match (t, l) { (Some(t), CodeLang::React) => format!("<{t} />"), + (Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)), + (None, CodeLang::Leptos) => "|| ()".into(), (Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"), (Some(t), _) => format!("{{ {t}() }}"), (None, CodeLang::React) => "null".into(), @@ -486,10 +547,17 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option } let t = &c.type_name; Some(match lang { - CodeLang::React => { + CodeLang::React | CodeLang::Leptos => { + let leptos = lang == CodeLang::Leptos; // Plain strings read best as attribute values. - let attrs: String = - args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect(); + let attrs: String = args + .iter() + .map(|(name, expr, plain)| { + let name = if leptos { rust_ident(name) } else { name.to_string() }; + if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") } + }) + .collect(); + let t = if leptos { rust_type(t) } else { t.clone() }; format!("{pad}<{t}{attrs} />\n") } CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")), @@ -504,13 +572,20 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S let kids = ctx.kids(n); match lang { CodeLang::React => { - let mut m = Markup::new(&ctx, true); + let mut m = Markup::new(&ctx, CodeLang::React); for k in kids { m.node(k, Some(n), depth + 2, ""); } let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1)); format!("(\n{p2}<>\n{}{p2}\n{p1})", m.markup) } + CodeLang::Leptos => { + let mut m = Markup::new(&ctx, CodeLang::Leptos); + for k in kids { + m.node(k, Some(n), depth + 1, ""); + } + format!("|| view! {{\n{}{}}}", m.markup, indent(depth)) + } CodeLang::SwiftUi => { let mut out = String::new(); for k in kids { @@ -535,6 +610,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { match lang { CodeLang::SwiftUi => swift_definition(doc, c), CodeLang::Compose => compose_definition(doc, c), + CodeLang::Leptos => leptos(doc, c), _ => react(doc, c), } } @@ -545,23 +621,29 @@ fn data_attr(p: &Param) -> String { format!("data-{}", class_name(&p.prop, &mut used)) } -/// A TSX function component with its props interface, and its stylesheet (variables as custom -/// properties, other variants as rules on their `data-*` attributes). -fn react(doc: &Document, c: &Component) -> String { +/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom +/// properties, other variants as rules on their `data-*` attributes): (root class, markup, CSS). +fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) { let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 }; let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default(); let root_class = class_name(&root_name, &mut Vec::new()); let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect(); let pass = |root: NodeId| { let vctx = Ctx { doc, root, comp: Some(c), base: 2 }; - let mut m = Markup::new(&vctx, true); + let mut m = Markup::new(&vctx, lang); m.root_class = Some(root_class.clone()); m.node(root, None, 2, ""); m.rules }; - let mut m = Markup::new(&ctx, true); + let mut m = Markup::new(&ctx, lang); m.root_class = Some(root_class.clone()); - m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect(); + m.root_attrs = variants + .iter() + .map(|p| match lang { + CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)), + _ => format!(" {}={{{}}}", data_attr(p), p.ident), + }) + .collect(); m.node(c.comp, None, 2, ""); // Other variants: what differs from this one, layer by layer. let mut variant_rules = String::new(); @@ -584,6 +666,13 @@ fn react(doc: &Document, c: &Component) -> String { variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::())); } } + let css = format!("{}{}{variant_rules}", m.tokens.root_rule(), m.styles); + (root_class, m.markup, css) +} + +/// A TSX function component with its props interface, and its stylesheet. +fn react(doc: &Document, c: &Component) -> String { + let (root_class, markup, css) = web(doc, c, CodeLang::React); let t = &c.type_name; let mut out = format!("import \"./{root_class}.css\";\n\n"); if c.params.is_empty() { @@ -610,7 +699,46 @@ fn react(doc: &Document, c: &Component) -> String { .collect(); out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::())); } - out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles)); + out.push_str(&format!(" return (\n{markup} );\n}}\n\n/* {root_class}.css */\n{css}")); + out +} + +/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes), +/// and its stylesheet. +fn leptos(doc: &Document, c: &Component) -> String { + let (root_class, markup, css) = web(doc, c, CodeLang::Leptos); + let mut out = String::from("use leptos::prelude::*;\n\n"); + for p in &c.params { + let ParamKind::Variant(opts) = &p.kind else { continue }; + let (e, cases) = (leptos_enum(c, p), rust_cases(opts)); + let default = opts.iter().position(|o| Some(o.as_str()) == p.default.as_str()).unwrap_or(0); + out.push_str(&format!("#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum {e} {{\n")); + for (i, case) in cases.iter().enumerate() { + out.push_str(&format!("{} {case},\n", if i == default { " #[default]\n" } else { "" })); + } + out.push_str(&format!("}}\n\nimpl {e} {{\n /// The variant's name, as its `data-*` attribute.\n pub fn as_str(self) -> &'static str {{\n match self {{\n")); + for (o, case) in opts.iter().zip(&cases) { + out.push_str(&format!(" Self::{case} => {},\n", rust_string(o))); + } + out.push_str(" }\n }\n}\n\n"); + } + let t = rust_type(&c.type_name); + out.push_str(&format!("#[component]\npub fn {t}(")); + if !c.params.is_empty() { + out.push('\n'); + for p in &c.params { + let default = value_expr(doc, c, p, &p.default, CodeLang::Leptos); + let (attr, ty) = match &p.kind { + ParamKind::Variant(_) => ("optional".to_string(), leptos_enum(c, p)), + ParamKind::Boolean => (format!("default = {default}"), "bool".into()), + ParamKind::Text => (format!("into, default = {default}.to_string()"), "String".into()), + ParamKind::InstanceSwap => (format!("into, default = ({default}).into()"), "ViewFn".into()), + ParamKind::Slot => ("optional, into".to_string(), "Option".into()), + }; + out.push_str(&format!(" #[prop({attr})] {}: {ty},\n", rust_ident(&p.ident))); + } + } + out.push_str(&format!(") -> impl IntoView {{\n view! {{\n{markup} }}\n}}\n\n/* {root_class}.css */\n{css}")); out } diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 2457171..c92815a 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -1,5 +1,5 @@ -//! Component code: components become React / SwiftUI / Compose components whose properties are -//! parameters, and instances become calls passing the values they set. +//! Component code: components become React / Leptos / SwiftUI / Compose components whose +//! properties are parameters, and instances become calls passing the values they set. use crate::Session; use crate::codegen::{CodeLang, code_for}; @@ -71,6 +71,18 @@ fn components_take_their_properties_as_parameters() { for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] { assert!(kt.contains(want), "Compose has {want:?}:\n{kt}"); } + let leptos = code(&mut s, c, "leptos"); + for want in [ + "use leptos::prelude::*;\n\n#[component]\npub fn Card(\n", + " #[prop(default = true)] show_dot: bool,\n", + " #[prop(into, default = \"Hello\".to_string())] title: String,\n", + ") -> impl IntoView {\n view! {\n", + "{show_dot.then(|| view! {", + ">{title.clone()}

", + "/* card.css */", + ] { + assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}"); + } // The command describes the properties. let v = component_code(&mut s, c, "react"); assert_eq!(v["name"], "Card"); @@ -105,11 +117,13 @@ fn instances_are_calls_passing_the_values_they_set() { assert!(react.contains("export function Card({")); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); } #[test] @@ -125,6 +139,9 @@ fn nested_instances_in_a_layer_tree_are_calls() { assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}"); assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")")); assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")")); + let leptos = code(&mut s, screen, "leptos"); + assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains(""), "{leptos}"); + assert!(!leptos.contains("Hello"), "{leptos}"); // HTML and CSS stay plain markup. assert!(code(&mut s, screen, "html").contains("Inbox")); } @@ -156,12 +173,23 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + let leptos = code(&mut s, set, "leptos"); + for want in [ + "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", + " Self::Hover => \"Hover\",", + " #[prop(optional)] state: ButtonState,", + "data-state={state.as_str()}", + ".button[data-state=\"Hover\"] .bg {\n background: #0000FF;", + ] { + assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}"); + } // An instance showing the hover variant passes it. let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600}))); ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "