diff --git a/README.md b/README.md index 2e91506a..e17cd74c 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,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 d1c03fe3..271193e0 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,8 @@ 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::ArkUi)) + CodeLang::from_id(s) + .filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -66,6 +67,8 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { _ if path.is_empty() => String::new(), CodeLang::React | CodeLang::ArkUi => 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}"), } } @@ -266,6 +269,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), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), @@ -280,22 +285,155 @@ fn pieces(t: &str) -> Vec> { checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)]) } +/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`). +fn inside_open_tag(s: &str) -> bool { + let mut in_tag = false; + let mut quote = None; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match quote { + Some(q) => { + if c == q { + quote = None; + } + } + None => match c { + '<' if !in_tag && chars.peek().is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => { + in_tag = true; + } + '>' if in_tag => in_tag = false, + '"' | '\'' if in_tag => quote = Some(c), + _ => {} + }, + } + } + in_tag +} + +/// Where a placeholder sits in a Vue template: whether the attribute is bound, and the quote the +/// template already opened (if any). `None` when it isn't in an attribute at all. +fn vue_attribute(out: &str) -> Option<(bool, Option)> { + let end = out.trim_end(); + if !inside_open_tag(end) { + return None; + } + let (after, quote) = match end.chars().last() { + Some(q @ ('"' | '\'')) => (&end[..end.len() - q.len_utf8()], Some(q)), + _ => (end, None), + }; + let after = after.trim_end(); + let eq = after.rfind('=')?; + // The placeholder must follow the `=` (or its opening quote) directly; text after a closed + // attribute (``) is template content, not an attribute value. + if !after[eq + 1..].trim().is_empty() { + return None; + } + let before = after[..eq].trim_end(); + let name: String = before + .chars() + .rev() + .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '@' | '.' | '[' | ']')) + .collect::>() + .into_iter() + .rev() + .collect(); + if name.is_empty() { + return None; + } + let bound = name.starts_with(':') || name.starts_with("v-bind:") || name.starts_with('@') || name.starts_with("v-on:") || name.starts_with("v-"); + Some((bound, quote)) +} + +/// An expression written into an HTML attribute opened with `quote`: its own delimiter and markup +/// become entities, so Vue's parser hands the expression its string literal intact. +fn html_attr_expr(e: &str, quote: Option) -> String { + let e = e.replace('&', "&").replace('<', "<"); + match quote { + Some('\'') => e.replace('\'', "'"), + _ => e.replace('"', """), + } +} + +/// Literal text written into an HTML attribute opened with `quote`. +fn html_attr_text(s: &str, quote: Option) -> String { + let s = s.replace('&', "&").replace('<', "<"); + match quote { + Some('\'') => s.replace('\'', "'"), + _ => s.replace('"', """), + } +} + +/// Literal text in Vue template content: braces would start an interpolation, so they are escaped. +fn html_text(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('{', "{").replace('}', "}") +} + /// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`. pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String { let mut out = String::new(); + // A quoted attribute stays open across substitutions: `title="{a} {b}"` must treat both + // placeholders as attribute values, not text after the first one. + let mut vue_attr: Option<(bool, Option)> = None; for piece in pieces(&link.template) { match piece { - Piece::Text(t) => out.push_str(t), + Piece::Text(t) => { + out.push_str(t); + if let Some((_, Some(q))) = vue_attr + && t.contains(q) + { + vue_attr = None; + } + } Piece::Value { name, inner } => match find(c, values, name) { Some((p, v)) => { 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); + // Vue's bound attributes take an expression, so a string keeps its JS quotes + // inside the attribute (as entities); a static attribute takes plain text. + if lang == CodeLang::Vue { + match vue_attr.or_else(|| vue_attribute(&out)) { + Some((bound, quote)) => { + if bound { + let expr = html_attr_expr(&e, quote); + if quote.is_some() { + out.push_str(&expr); + } else { + out.push('"'); + out.push_str(&expr); + out.push('"'); + } + } else { + let text = match &p.kind { + ParamKind::Text | ParamKind::Variant(_) => html_attr_text(v.as_str().unwrap_or(""), quote), + _ => e.clone(), + }; + if quote.is_some() { + out.push_str(&text); + } else { + out.push('"'); + out.push_str(&text); + out.push('"'); + } + } + vue_attr = quote.map(|q| (bound, Some(q))); + } + None => { + let text = match &p.kind { + ParamKind::Text | ParamKind::Variant(_) => html_text(v.as_str().unwrap_or("")), + _ => e.clone(), + }; + out.push_str(&text); + vue_attr = None; + } + } } 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}}}")), @@ -317,6 +455,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(", ")), CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), @@ -338,6 +477,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res let path_ok = match lang { CodeLang::React | CodeLang::ArkUi => !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 { @@ -345,6 +485,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), CodeLang::ArkUi => 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"), }); } @@ -380,7 +521,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|arkui")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")), None => Ok(CodeLang::React), } } @@ -410,7 +551,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (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|arkui (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"; @@ -525,7 +666,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|arkui), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui), 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 6cdd8e67..1ee785e6 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -193,3 +193,79 @@ 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. A bound attribute takes an expression, so a + // string keeps its JS quotes inside the attribute (as entities): `Buy` alone would be a + // variable, not the string. + 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 plain string attribute (no `:`) takes the text itself, and a variant binding is a string + // expression like any other. + ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": ""})); + assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n\n"); + let (set, hover) = button(&mut s); + let b = idv(&ex(&mut s, "instance.create", json!({"component": hover, "x": 0, "y": 700}))); + ex(&mut s, "codeConnect.set", json!({"id": b, "language": "vue", "template": "