diff --git a/README.md b/README.md index ae4c9f24c..b858b5c0c 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, 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. + 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, Vue, 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 463406368..edbae279f 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::Leptos | CodeLang::SwiftUi | CodeLang::Compose)) + CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -66,6 +66,8 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { _ 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()), + // A single-file component is a default export. + CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), _ => format!("import {path}"), } } @@ -174,6 +176,8 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> match (swapped, lang) { (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), + // A Vue binding takes the component itself. + (Some(l), CodeLang::Vue) => l.name.clone(), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), @@ -195,11 +199,24 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], let e = expr(doc, c, p, v, lang); // 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); + // A Vue binding takes a quoted value. A plain string literal brings its own + // quotes; the template's own quote, when it wrote one, ends it; anything else + // is wrapped here, and a quote inside is escaped for the attribute. + if lang == CodeLang::Vue { + if !out.ends_with('"') && e.starts_with('"') && !e.contains('\\') { + out.push_str(&e); + } else if out.ends_with('"') { + out.push_str(&e.replace('"', """)); + } else { + out.push_str(&format!("\"{}\"", e.replace('"', """))); + } } else { - out.push_str(&format!("{{{e}}}")); + 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}}}")); + } } } None => out.push_str(&format!("{{{inner}}}")), @@ -221,6 +238,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { CodeLang::Leptos => { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) } + CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{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(", ")), } @@ -241,12 +259,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res 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 == ':'), + CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(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"), + CodeLang::Vue => format!("importPath: a 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"), }); } @@ -282,7 +302,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|leptos|swiftui|compose")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose")), None => Ok(CodeLang::React), } } @@ -312,7 +332,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{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)}", + "{id? (component, set or instance; default the selection), language?: react|leptos|vue|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"; @@ -427,7 +447,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|leptos|swiftui|compose), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|vue|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 9d657d5f1..eb34656b6 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -183,3 +183,38 @@ fn code_connect_commands_survive_junk() { } } } + +#[test] +fn vue_links_are_a_default_import_with_quoted_bindings() { + let mut s = Session::new(); + let c = card(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}})); + // A single-file component is a default import, and a binding's value is quoted (the template + // writes `:prop={prop}`, so a plain string brings its own quotes). + let v = ex( + &mut s, + "codeConnect.set", + json!({"id": i, "language": "vue", "importPath": "@/components/Card.vue", + "template": ""}), + ); + let want = "import Card from \"@/components/Card.vue\";\n\n\n"; + assert_eq!(v["code"], want); + assert_eq!(code(&mut s, i, "vue"), want); + assert_eq!(ex(&mut s, "inspect.code", json!({"id": i, "language": "vue"}))["connected"], true); + // No template given: the generated one binds a quoted value for every property. + ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "vue"})); + ex(&mut s, "codeConnect.set", json!({"id": c, "language": "vue", "importPath": "./Card.vue"})); + assert_eq!(code(&mut s, c, "vue"), "import Card from \"./Card.vue\";\n\n\n"); + // A string with escapes stays one value: the attribute delimiter is what gets escaped, so the + // binding stays a single well-formed attribute. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}})); + let escaped = code(&mut s, i, "vue"); + let line = escaped.lines().find(|l| l.contains(""")).unwrap_or_default(); + assert!(line.starts_with(""), "{line}"); + // Vue is linkable; a language that isn't stays refused. + assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "css"})).is_err(), "not a code language"); + assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "vue", "importPath": "a b"})).is_err(), "no spaces in a path"); +} diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886d..618d430a5 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|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}", + "{id?, language?: css|html|react|leptos|vue|swiftui|compose (default css; html, react, leptos and vue 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, Leptos and Vue; 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|leptos|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -61,18 +61,22 @@ pub enum CodeLang { React, /// A Leptos component (`view!` markup) with its stylesheet. Leptos, + /// A Vue single-file component (`