mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
Merge pull request #664 from phonedu/feat/vue-code-target
M6: a Vue code target (single-file components)
This commit is contained in:
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), 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 <code>count + 1</code>), 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.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -37,7 +37,8 @@ const MAX_WARNINGS: usize = 50;
|
||||
|
||||
/// The languages a component can be connected in.
|
||||
pub fn language(s: &str) -> Option<CodeLang> {
|
||||
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<Piece<'_>> {
|
||||
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<char>)> {
|
||||
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 (`<Card title='Buy'>`) 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::<Vec<_>>()
|
||||
.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<char>) -> 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<char>) -> 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<char>)> = 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::<String>())
|
||||
}
|
||||
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::<String>()),
|
||||
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().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<Option<String>> {
|
||||
/// The `language` parameter (default React).
|
||||
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
||||
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<CommandSpec> {
|
||||
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<Value> {
|
||||
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 {
|
||||
|
||||
@@ -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": "<Card :title={title} :dotted={Show dot} />"}),
|
||||
);
|
||||
let want = "import Card from \"@/components/Card.vue\";\n\n<Card :title=\""Buy"\" :dotted=\"false\" />\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<Card :showDot=\"true\" :title=\""Hello"\" />\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": "<Card title={title} :dotted={Show dot} />"}));
|
||||
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Buy\" :dotted=\"false\" />\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": "<Button :state={state} />"}));
|
||||
assert_eq!(code(&mut s, b, "vue"), "<Button :state=\""Hover"\" />\n");
|
||||
assert_eq!(id_of(&ex(&mut s, "codeConnect.set", json!({"id": set}))["component"]), Some(set));
|
||||
// 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, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card :title={title} :dotted={Show dot} />"}));
|
||||
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("<Card :title=\""Say"), "{line}");
|
||||
assert!(line.contains(":dotted=\"false\""), "{line}");
|
||||
assert!(line.ends_with("\" />"), "{line}");
|
||||
// A single-quoted attribute keeps its delimiter (the expression's double quotes stay), and a
|
||||
// placeholder in template content is text, not a quoted string.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Buy"}}));
|
||||
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card :title='{title}' :dotted='{Show dot}' />"}));
|
||||
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card :title='\"Buy\"' :dotted='false' />\n");
|
||||
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title='{title}'>{title}</Card>"}));
|
||||
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title='Buy'>Buy</Card>\n");
|
||||
// A second placeholder in the same quoted attribute is still attribute text, and its value's
|
||||
// quotes are escaped.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}}));
|
||||
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title=\"{title} {title}\" />"}));
|
||||
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Say "hi" Say "hi"\" />\n");
|
||||
// Text after a completed tag is text, even when it contains `=`.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Buy"}}));
|
||||
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<p>Total = {title}</p>"}));
|
||||
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<p>Total = Buy</p>\n");
|
||||
// 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vue_layer_trees_import_connected_components_in_script_setup() {
|
||||
let mut s = Session::new();
|
||||
let c = card(&mut s);
|
||||
ex(&mut s, "codeConnect.set", json!({"id": c, "language": "vue", "importPath": "./Card.vue"}));
|
||||
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"})));
|
||||
ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen}));
|
||||
// A single-file component's imports belong in its `<script setup>`, not before the template.
|
||||
let code = code(&mut s, screen, "vue");
|
||||
let want = "<script setup lang=\"ts\">\n\nimport Card from \"./Card.vue\";\n\n</script>\n\n<template>\n";
|
||||
assert!(code.starts_with(want), "{code}");
|
||||
assert_eq!(code.matches("import Card").count(), 1, "{code}");
|
||||
}
|
||||
|
||||
+132
-27
@@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose|arkui (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; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), 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|tailwind|swiftui|compose|arkui (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; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), 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|tailwind|swiftui|compose|arkui"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?,
|
||||
None => CodeLang::Css,
|
||||
};
|
||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
||||
@@ -68,6 +68,8 @@ pub enum CodeLang {
|
||||
React,
|
||||
/// A Leptos component (`view!` markup) with its stylesheet.
|
||||
Leptos,
|
||||
/// A Vue single-file component (`<template>`, `<script setup>`, `<style scoped>`).
|
||||
Vue,
|
||||
/// A React function component styled with Tailwind CSS v4 utilities (no stylesheet).
|
||||
Tailwind,
|
||||
SwiftUi,
|
||||
@@ -77,14 +79,24 @@ pub enum CodeLang {
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 8] =
|
||||
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi];
|
||||
pub const ALL: [CodeLang; 9] = [
|
||||
CodeLang::Css,
|
||||
CodeLang::Html,
|
||||
CodeLang::React,
|
||||
CodeLang::Leptos,
|
||||
CodeLang::Vue,
|
||||
CodeLang::Tailwind,
|
||||
CodeLang::SwiftUi,
|
||||
CodeLang::Compose,
|
||||
CodeLang::ArkUi,
|
||||
];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CodeLang::Css => "css",
|
||||
CodeLang::Html => "html",
|
||||
CodeLang::React => "react",
|
||||
CodeLang::Leptos => "leptos",
|
||||
CodeLang::Vue => "vue",
|
||||
CodeLang::Tailwind => "tailwind",
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
@@ -97,6 +109,7 @@ impl CodeLang {
|
||||
CodeLang::Html => "HTML + CSS",
|
||||
CodeLang::React => "React",
|
||||
CodeLang::Leptos => "Leptos",
|
||||
CodeLang::Vue => "Vue",
|
||||
CodeLang::Tailwind => "React + Tailwind",
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
@@ -106,12 +119,15 @@ impl CodeLang {
|
||||
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
|
||||
/// are utility classes).
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Vue)
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<CodeLang> {
|
||||
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
||||
return Some(CodeLang::React);
|
||||
}
|
||||
if s.eq_ignore_ascii_case("sfc") || s.eq_ignore_ascii_case("vue3") {
|
||||
return Some(CodeLang::Vue);
|
||||
}
|
||||
if s.eq_ignore_ascii_case("arkts") {
|
||||
return Some(CodeLang::ArkUi);
|
||||
}
|
||||
@@ -226,7 +242,9 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => {
|
||||
crate::component_codegen::code(doc, id, lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +285,13 @@ impl<'a> Ctx<'a> {
|
||||
depth > self.base + MAX_DEPTH
|
||||
}
|
||||
|
||||
/// A call for an instance below the root (`None` for other layers, or a missing main).
|
||||
fn call(&self, n: &Node, lang: CodeLang, depth: usize) -> Option<String> {
|
||||
/// A call for an instance below the root (`None` for other layers, or a missing main), with
|
||||
/// the CSS its slot content generated.
|
||||
fn call(&self, n: &Node, lang: CodeLang, depth: usize) -> Option<(String, String)> {
|
||||
if n.id == self.root || !matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
return None;
|
||||
}
|
||||
crate::component_codegen::usage(self.doc, n.id, lang, depth)
|
||||
crate::component_codegen::usage_with_styles(self.doc, n.id, lang, depth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,6 +711,35 @@ pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> Stri
|
||||
name
|
||||
}
|
||||
|
||||
/// The index of `markup`'s first tag-closing `>`, skipping `>` inside quoted attribute values.
|
||||
fn tag_end(markup: &str) -> Option<usize> {
|
||||
let mut quote = None;
|
||||
for (i, c) in markup.char_indices() {
|
||||
match quote {
|
||||
Some(q) => {
|
||||
if c == q {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
None => match c {
|
||||
'"' | '\'' => quote = Some(c),
|
||||
'>' => return Some(i),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `markup`'s first element with a Vue `v-if` attribute (a call with no placing wrapper).
|
||||
fn with_v_if(markup: &str, guard: Option<&str>) -> String {
|
||||
let Some(guard) = guard else { return markup.to_string() };
|
||||
let Some(end) = tag_end(markup) else { return markup.to_string() };
|
||||
let before = markup[..end].trim_end();
|
||||
let at = if before.ends_with('/') { before.len() - 1 } else { end };
|
||||
format!("{} v-if=\"{guard}\"{}", &markup[..at], &markup[at..])
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', "<br>")
|
||||
}
|
||||
@@ -723,12 +771,13 @@ fn html(doc: &Document, id: NodeId) -> String {
|
||||
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
|
||||
/// (`{{ }}` interpolation, `v-if`, `:attr` bindings) or Leptos `view!` markup (text as Rust
|
||||
/// string literals); the last three with component parameters and instances as calls. With its
|
||||
/// stylesheet.
|
||||
pub(crate) struct Markup<'c, 'a> {
|
||||
ctx: &'c Ctx<'a>,
|
||||
/// Html, React or Leptos.
|
||||
/// Html, React, Vue or Leptos.
|
||||
lang: CodeLang,
|
||||
used: Vec<String>,
|
||||
pub markup: String,
|
||||
@@ -737,6 +786,12 @@ pub(crate) struct Markup<'c, 'a> {
|
||||
/// The root's class (default: from its name) and extra JSX attributes.
|
||||
pub root_class: Option<String>,
|
||||
pub root_attrs: String,
|
||||
/// A Vue `v-if` guard for the element about to be written: Vue templates can't wrap an
|
||||
/// element in an expression, so a boolean parameter's layer carries the condition itself.
|
||||
v_if: Option<String>,
|
||||
/// A prefix for every generated class name (slot content joins the caller's stylesheet, where
|
||||
/// two slots' layer names may repeat).
|
||||
pub class_prefix: Option<String>,
|
||||
/// Each layer's path in the tree (names with sibling counts), class and declarations, so
|
||||
/// variants can be compared layer by layer.
|
||||
pub rules: Vec<(String, String, String)>,
|
||||
@@ -761,6 +816,8 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
tokens: Tokens::default(),
|
||||
root_class: None,
|
||||
root_attrs: String::new(),
|
||||
v_if: None,
|
||||
class_prefix: None,
|
||||
rules: Vec::new(),
|
||||
inline: false,
|
||||
fluid: false,
|
||||
@@ -768,9 +825,9 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// JSX indents by two spaces, HTML and Leptos by four.
|
||||
/// JSX and Vue templates indent by two spaces, HTML and Leptos by four.
|
||||
fn pad(&self, depth: usize) -> String {
|
||||
if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
|
||||
if matches!(self.lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) }
|
||||
}
|
||||
|
||||
/// Component code (React or Leptos): parameters and instances as calls.
|
||||
@@ -790,6 +847,13 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
&& id != self.ctx.root
|
||||
&& let Some(p) = self.ctx.param(n, "visible")
|
||||
{
|
||||
// A Vue template holds no arbitrary expressions, so the guard rides on the element.
|
||||
if self.lang == CodeLang::Vue {
|
||||
self.v_if = Some(self.ident(p));
|
||||
self.element(n, parent, depth, path);
|
||||
self.v_if = None;
|
||||
return;
|
||||
}
|
||||
let (pad, p) = (self.pad(depth), self.ident(p));
|
||||
let (open, close) =
|
||||
if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
|
||||
@@ -818,7 +882,13 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
self.used.push(c.clone());
|
||||
c
|
||||
}
|
||||
None => class_name(&n.name, kind_word(n), &mut self.used),
|
||||
None => {
|
||||
let class = class_name(&n.name, kind_word(n), &mut self.used);
|
||||
match &self.class_prefix {
|
||||
Some(prefix) => format!("{prefix}{class}"),
|
||||
None => class,
|
||||
}
|
||||
}
|
||||
};
|
||||
let pad = self.pad(depth);
|
||||
let attr = if self.lang == CodeLang::React { "className" } else { "class" };
|
||||
@@ -852,15 +922,20 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
if self.code() && (!self.inline || connected) && !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!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
|
||||
None => self.ctx.call(n, self.lang, at),
|
||||
let (inner, css) = match self.ctx.param(n, "mainComponent") {
|
||||
Some(p) if self.lang == CodeLang::Vue => (Some(format!("{}<component :is=\"{}\" />\n", self.pad(at), self.ident(p))), String::new()),
|
||||
Some(p) => (Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), String::new()),
|
||||
None => self.ctx.call(n, self.lang, at).map_or((None, String::new()), |(code, css)| (Some(code), css)),
|
||||
};
|
||||
self.styles.push_str(&css);
|
||||
if let Some(inner) = inner {
|
||||
// A Vue guard set by `node` rides on the call when there is no wrapper.
|
||||
let guard = if self.lang == CodeLang::Vue { self.v_if.take() } else { None };
|
||||
if decls.is_empty() {
|
||||
self.markup.push_str(&inner);
|
||||
self.markup.push_str(&with_v_if(&inner, guard.as_deref()));
|
||||
} else {
|
||||
self.markup.push_str(&format!("{pad}<div {attr}=\"{class}\">\n{inner}{pad}</div>\n"));
|
||||
let guard = guard.map(|g| format!(" v-if=\"{g}\"")).unwrap_or_default();
|
||||
self.markup.push_str(&format!("{pad}<div {attr}=\"{class}\"{guard}>\n{inner}{pad}</div>\n"));
|
||||
self.rule(path, &class, decls);
|
||||
}
|
||||
return;
|
||||
@@ -879,18 +954,27 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
}
|
||||
decls.push_str(&own);
|
||||
self.rule(path, &class, decls);
|
||||
let attrs = if root { self.root_attrs.clone() } else { String::new() };
|
||||
// The root's variant attributes, and a Vue `v-if` guard, ride on the open tag.
|
||||
let mut attrs = if root { self.root_attrs.clone() } else { String::new() };
|
||||
if self.lang == CodeLang::Vue
|
||||
&& let Some(guard) = self.v_if.take()
|
||||
{
|
||||
attrs.push_str(&format!(" v-if=\"{guard}\""));
|
||||
}
|
||||
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.code()), self.lang) {
|
||||
(Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
|
||||
(Some(p), CodeLang::Vue) => format!("{{{{ {} }}}}", 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::<String>().lines().map(|l| format!("{l:?}")).collect::<Vec<_>>().join("<br/>")
|
||||
}
|
||||
// A Vue template reads `{{ }}` as interpolation, so braces are escaped.
|
||||
(None, CodeLang::Vue) => html_escape(&t.characters).replace('{', "{").replace('}', "}"),
|
||||
(None, _) => html_escape(&t.characters),
|
||||
};
|
||||
self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
|
||||
@@ -898,11 +982,26 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
_ => {
|
||||
let kids = self.ctx.kids(n);
|
||||
let slot = self.ctx.param(n, "slot").filter(|_| self.code());
|
||||
let vue = self.lang == CodeLang::Vue;
|
||||
let leptos = self.lang == CodeLang::Leptos;
|
||||
let open = open("div");
|
||||
let slot_ident = slot.map(|p| match self.ctx.comp {
|
||||
Some(c) => crate::component_codegen::slot_name(c, p),
|
||||
None => class_name(&p.ident, "slot", &mut Vec::new()),
|
||||
});
|
||||
if let Some(p) = slot.filter(|_| kids.is_empty()) {
|
||||
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}}}</div>\n"));
|
||||
let content = if leptos {
|
||||
format!("{{{}}}.as_ref().map(|c| c.run())", self.ident(p))
|
||||
} else if vue {
|
||||
// Vue's own slot element, named so an instance's content lands in the right one.
|
||||
match slot_ident.clone() {
|
||||
Some(name) => format!("<slot name=\"{name}\" />"),
|
||||
None => "<slot />".into(),
|
||||
}
|
||||
} else {
|
||||
format!("{{{}}}", p.ident)
|
||||
};
|
||||
self.markup.push_str(&format!("{open}{content}</div>\n"));
|
||||
} else if kids.is_empty() || self.ctx.too_deep(depth) {
|
||||
self.markup.push_str(&format!("{open}</div>\n"));
|
||||
} else {
|
||||
@@ -910,6 +1009,11 @@ 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(_) if vue => {
|
||||
let name = slot_ident.clone().map(|n| format!(" name=\"{n}\"")).unwrap_or_default();
|
||||
self.markup.push_str(&format!("{p1}<slot{name}>\n"));
|
||||
depth + 2
|
||||
}
|
||||
Some(p) if leptos => {
|
||||
self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
|
||||
depth + 2
|
||||
@@ -929,6 +1033,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
|
||||
}
|
||||
match slot {
|
||||
Some(_) if vue => self.markup.push_str(&format!("{p1}</slot>\n")),
|
||||
Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
|
||||
Some(_) => self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")),
|
||||
None => {}
|
||||
@@ -1013,7 +1118,7 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
|
||||
// Nested instances are calls; an instance swap parameter is a view.
|
||||
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
|
||||
Some(p) => Some(format!("{pad}{}\n", p.ident)),
|
||||
None => ctx.call(n, CodeLang::SwiftUi, depth),
|
||||
None => ctx.call(n, CodeLang::SwiftUi, depth).map(|(code, _)| code),
|
||||
};
|
||||
if let Some(call) = call {
|
||||
out.push_str(&call);
|
||||
@@ -1186,7 +1291,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
// Nested instances are calls; an instance swap parameter is a composable.
|
||||
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
|
||||
Some(p) => Some(format!("{pad}{}()\n", p.ident)),
|
||||
None => ctx.call(n, CodeLang::Compose, depth),
|
||||
None => ctx.call(n, CodeLang::Compose, depth).map(|(code, _)| code),
|
||||
};
|
||||
if let Some(call) = call {
|
||||
out.push_str(&call);
|
||||
@@ -1304,7 +1409,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
|
||||
let swap = ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root);
|
||||
let call = match swap {
|
||||
Some(p) => Some(format!("{pad}this.{}()\n", p.ident)),
|
||||
None => ctx.call(n, CodeLang::ArkUi, depth),
|
||||
None => ctx.call(n, CodeLang::ArkUi, depth).map(|(code, _)| code),
|
||||
};
|
||||
if let Some(call) = call {
|
||||
if let Some(p) = swap.filter(|_| !extra.is_empty()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack
|
||||
//! Component code: a component (or component set) becomes a React, Leptos, Vue, SwiftUI, Jetpack
|
||||
//! Compose or ArkUI 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.
|
||||
@@ -68,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.componentCode",
|
||||
"Component code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (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|vue|swiftui|compose|arkui (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::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")),
|
||||
let lang = match string(p, "language") {
|
||||
// One list of code languages, shared with codeConnect (see `code_connect::language`).
|
||||
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui"))?,
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its
|
||||
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose or ArkUI: 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), with the imports of the connected components it calls
|
||||
/// at the top.
|
||||
@@ -122,9 +122,13 @@ 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 | CodeLang::Leptos => {
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::Vue => {
|
||||
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) }
|
||||
match lang {
|
||||
CodeLang::Leptos => leptos(doc, &c),
|
||||
CodeLang::Vue => vue(doc, &c),
|
||||
_ => react(doc, &c),
|
||||
}
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
@@ -143,7 +147,29 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
}
|
||||
},
|
||||
};
|
||||
crate::code_connect::with_imports(&crate::code_connect::tree_imports(doc, id, lang), code)
|
||||
let imports = crate::code_connect::tree_imports(doc, id, lang);
|
||||
if lang == CodeLang::Vue && !imports.is_empty() {
|
||||
return vue_with_imports(&imports, code);
|
||||
}
|
||||
crate::code_connect::with_imports(&imports, code)
|
||||
}
|
||||
|
||||
/// A Vue single-file component imports in its `<script setup>`, which is added when it has none.
|
||||
fn vue_with_imports(imports: &[String], code: String) -> String {
|
||||
const SETUP: &str = "<script setup lang=\"ts\">\n\n";
|
||||
let lines = imports.join("\n");
|
||||
// `find` returns char boundaries, so the splits below are safe.
|
||||
if let Some(at) = code.find(SETUP).map(|at| at + SETUP.len()) {
|
||||
let (head, tail) = code.split_at(at);
|
||||
return format!("{head}{lines}\n\n{tail}");
|
||||
}
|
||||
match code.find("<template>") {
|
||||
Some(at) => {
|
||||
let (head, tail) = code.split_at(at);
|
||||
format!("{head}{SETUP}{lines}\n\n</script>\n\n{tail}")
|
||||
}
|
||||
None => crate::code_connect::with_imports(imports, code),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_component(doc: &Document, id: NodeId) -> bool {
|
||||
@@ -466,6 +492,23 @@ pub(crate) fn js_string(s: &str) -> String {
|
||||
serde_json::to_string(&s).unwrap_or_else(|_| "\"\"".into())
|
||||
}
|
||||
|
||||
/// A JavaScript string literal safe inside a Vue single-file component: `<` is escaped, so a
|
||||
/// default containing `</script>` can't close the `<script setup>` block early.
|
||||
pub(crate) fn vue_string(s: &str) -> String {
|
||||
js_string(s).replace('<', "\\u003C")
|
||||
}
|
||||
|
||||
/// A plain Vue attribute value: its JS string quotes stay as the attribute delimiters, and HTML
|
||||
/// entities inside are escaped so the value reads back unchanged.
|
||||
fn html_attr_value(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// A Vue binding expression inside a double-quoted attribute.
|
||||
fn html_attr_expr(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// The type name of a component (a variant's set) by id.
|
||||
pub(crate) fn component_type(doc: &Document, comp: NodeId) -> Option<String> {
|
||||
let n = doc.node(comp).filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))?;
|
||||
@@ -479,8 +522,11 @@ fn enum_name(c: &Component, p: &Param, swift: bool) -> String {
|
||||
|
||||
/// `value` of a parameter as an expression in `lang`.
|
||||
pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value, lang: CodeLang) -> String {
|
||||
// React and Vue both take JavaScript: strings, and variant values as plain strings.
|
||||
let js = matches!(lang, CodeLang::React | CodeLang::Vue);
|
||||
match (&p.kind, lang) {
|
||||
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
|
||||
(ParamKind::Text, CodeLang::Vue) => vue_string(value.as_str().unwrap_or("")),
|
||||
(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) => {
|
||||
@@ -490,6 +536,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
|
||||
(ParamKind::Variant(opts), l) => {
|
||||
let v = value.as_str().unwrap_or("");
|
||||
if l == CodeLang::Vue {
|
||||
return vue_string(v);
|
||||
}
|
||||
if l == CodeLang::React {
|
||||
return js_string(v);
|
||||
}
|
||||
@@ -501,7 +550,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
(ParamKind::InstanceSwap, l) => {
|
||||
let t = id_of(value).and_then(|i| component_type(doc, i));
|
||||
match (t, l) {
|
||||
(Some(t), CodeLang::React) => format!("<{t} />"),
|
||||
// A Vue binding takes the component itself, not an element.
|
||||
(Some(t), CodeLang::Vue) => t,
|
||||
(Some(t), _) if js => format!("<{t} />"),
|
||||
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
|
||||
(None, CodeLang::Leptos) => "|| ()".into(),
|
||||
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||
@@ -509,7 +560,7 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
|
||||
(None, CodeLang::ArkUi) => "() => {}".into(),
|
||||
(Some(t), _) => format!("{{ {t}() }}"),
|
||||
(None, CodeLang::React) => "null".into(),
|
||||
(None, _) if js => "null".into(),
|
||||
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
|
||||
(None, _) => "{}".into(),
|
||||
}
|
||||
@@ -520,6 +571,19 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
|
||||
// ------------------------------------------------------------------ instances
|
||||
|
||||
/// The `<slot name="…">` of a slot parameter: unique among the component's slot parameters, unlike
|
||||
/// `class_name` called with a fresh list per parameter (two idents can lowercase alike).
|
||||
pub(crate) fn slot_name(c: &Component, p: &Param) -> String {
|
||||
let mut used = Vec::new();
|
||||
for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) {
|
||||
let name = class_name(&q.ident, "slot", &mut used);
|
||||
if q.ident == p.ident {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
class_name(&p.ident, "slot", &mut Vec::new())
|
||||
}
|
||||
|
||||
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
|
||||
/// values from the variant it shows, slots as whether they hold its own content.
|
||||
pub(crate) fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Option<Vec<(String, Value)>> {
|
||||
@@ -544,22 +608,32 @@ pub(crate) fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Opti
|
||||
/// The call for instance `id` at `depth`, passing the values that differ from the defaults and the
|
||||
/// content of slots it fills (`None` when it isn't an instance of an existing component).
|
||||
pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<String> {
|
||||
usage_with_styles(doc, id, lang, depth).map(|(code, _)| code)
|
||||
}
|
||||
|
||||
/// `usage` and the CSS generated for the slot content it carries (the caller's stylesheet needs
|
||||
/// those rules; `usage` alone drops them at a snippet boundary).
|
||||
pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<(String, String)> {
|
||||
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
|
||||
let values = instance_values(doc, id, &c)?;
|
||||
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
|
||||
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
|
||||
// A connected component: its real snippet (its import is at the top of the code: `code`).
|
||||
if let Some(link) = crate::code_connect::link(doc, id, lang) {
|
||||
return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad));
|
||||
return Some((crate::code_connect::render(doc, &c, &values, link, lang, &pad), String::new()));
|
||||
}
|
||||
let slots = slots_of(doc, id);
|
||||
// (parameter, expression, whether it's a plain string literal)
|
||||
let mut args: Vec<(&str, String, bool)> = Vec::new();
|
||||
let mut slot_css = String::new();
|
||||
for (p, (_, v)) in c.params.iter().zip(&values) {
|
||||
if let ParamKind::Slot = p.kind {
|
||||
if v.as_bool() == Some(true)
|
||||
&& let Some((layer, _)) = slots.iter().find(|(_, name)| *name == p.prop)
|
||||
{
|
||||
args.push((&p.ident, slot_content(doc, *layer, lang, depth), false));
|
||||
let prefix = format!("slot-{}-", slot_name(&c, p));
|
||||
let (content, css) = slot_content(doc, *layer, lang, depth, &prefix);
|
||||
slot_css.push_str(&css);
|
||||
args.push((&p.ident, content, false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -571,7 +645,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
||||
args.push((&p.ident, expr, plain));
|
||||
}
|
||||
let t = &c.type_name;
|
||||
Some(match lang {
|
||||
let code = match lang {
|
||||
CodeLang::React | CodeLang::Leptos => {
|
||||
let leptos = lang == CodeLang::Leptos;
|
||||
// Plain strings read best as attribute values.
|
||||
@@ -585,6 +659,31 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
||||
let t = if leptos { rust_type(t) } else { t.clone() };
|
||||
format!("{pad}<{t}{attrs} />\n")
|
||||
}
|
||||
CodeLang::Vue => {
|
||||
// A plain string is an attribute value and anything else a `:name` binding, named as
|
||||
// the definition declares the prop; a slot is a `<template #name>` child instead,
|
||||
// named as its `<slot>` element is.
|
||||
let is_slot = |name: &str| c.params.iter().any(|p| p.ident == name && matches!(p.kind, ParamKind::Slot));
|
||||
let attrs: String = args
|
||||
.iter()
|
||||
.filter(|(name, _, _)| !is_slot(name))
|
||||
.map(
|
||||
|(name, expr, plain)| {
|
||||
if *plain { format!(" {name}={}", html_attr_value(expr)) } else { format!(" :{name}=\"{}\"", html_attr_expr(expr)) }
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
let slots: String = args
|
||||
.iter()
|
||||
.filter(|(name, _, _)| is_slot(name))
|
||||
.map(|(name, expr, _)| {
|
||||
let slot = c.params.iter().find(|q| q.ident == *name);
|
||||
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new()));
|
||||
format!("{pad} <template #{name}>\n{expr}{pad} </template>\n")
|
||||
})
|
||||
.collect();
|
||||
if slots.is_empty() { format!("{pad}<{t}{attrs} />\n") } else { format!("{pad}<{t}{attrs}>\n{slots}{pad}</{t}>\n") }
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::ArkUi => {
|
||||
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
|
||||
@@ -592,50 +691,64 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
||||
if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") }
|
||||
}
|
||||
_ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::<Vec<_>>().join(", ")),
|
||||
})
|
||||
};
|
||||
Some((code, slot_css))
|
||||
}
|
||||
|
||||
/// The content an instance put in one of its slots, as an expression spanning lines.
|
||||
fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> String {
|
||||
let Some(n) = doc.node(slot) else { return String::new() };
|
||||
/// The content an instance put in one of its slots, as an expression spanning lines, and the CSS
|
||||
/// its layers generated (so the caller's stylesheet keeps it). `prefix` keeps two slots' class
|
||||
/// names apart when their layers share a name.
|
||||
fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize, prefix: &str) -> (String, String) {
|
||||
let Some(n) = doc.node(slot) else { return (String::new(), String::new()) };
|
||||
let ctx = Ctx { doc, root: slot, comp: None, base: depth };
|
||||
let kids = ctx.kids(n);
|
||||
match lang {
|
||||
CodeLang::React => {
|
||||
let mut m = Markup::new(&ctx, CodeLang::React);
|
||||
m.class_prefix = Some(prefix.to_string());
|
||||
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)
|
||||
(format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup), m.styles)
|
||||
}
|
||||
CodeLang::Leptos => {
|
||||
let mut m = Markup::new(&ctx, CodeLang::Leptos);
|
||||
m.class_prefix = Some(prefix.to_string());
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 1, "");
|
||||
}
|
||||
format!("|| view! {{\n{}{}}}", m.markup, indent(depth))
|
||||
(format!("|| view! {{\n{}{}}}", m.markup, indent(depth)), m.styles)
|
||||
}
|
||||
CodeLang::Vue => {
|
||||
// Just the markup: `usage` wraps it in the `<template #name>` of that slot.
|
||||
let mut m = Markup::new(&ctx, CodeLang::Vue);
|
||||
m.class_prefix = Some(prefix.to_string());
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 2, "");
|
||||
}
|
||||
(m.markup, m.styles)
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
swift(&ctx, k, depth + 2, &mut out, &[]);
|
||||
}
|
||||
format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1))
|
||||
(format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)), String::new())
|
||||
}
|
||||
CodeLang::ArkUi => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
arkui(&ctx, k, depth + 2, &mut out, &[]);
|
||||
}
|
||||
format!("() => {{\n{out}{}}}", indent(depth + 1))
|
||||
(format!("() => {{\n{out}{}}}", indent(depth + 1)), String::new())
|
||||
}
|
||||
_ => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
compose(&ctx, k, depth + 2, &mut out);
|
||||
}
|
||||
format!("{{\n{out}{}}}", indent(depth + 1))
|
||||
(format!("{{\n{out}{}}}", indent(depth + 1)), String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,6 +761,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||
CodeLang::Compose => compose_definition(doc, c),
|
||||
CodeLang::Leptos => leptos(doc, c),
|
||||
CodeLang::Vue => vue(doc, c),
|
||||
CodeLang::ArkUi => arkui_definition(doc, c),
|
||||
_ => react(doc, c),
|
||||
}
|
||||
@@ -681,6 +795,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
.zip(&attr_names)
|
||||
.map(|(p, attr)| match lang {
|
||||
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
|
||||
CodeLang::Vue => format!(" :{attr}=\"{}\"", p.ident),
|
||||
_ => format!(" {attr}={{{}}}", p.ident),
|
||||
})
|
||||
.collect();
|
||||
@@ -744,6 +859,39 @@ fn react(doc: &Document, c: &Component) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A Vue single-file component: `<script setup>` with typed props and their defaults, the
|
||||
/// template, and a scoped stylesheet. Slots become Vue's own `<slot>` elements, so they are not
|
||||
/// props. Variants switch on the `data-*` attributes of the root element, as on the web.
|
||||
fn vue(doc: &Document, c: &Component) -> String {
|
||||
// The stylesheet sits inline in the SFC, so the root class name isn't needed here.
|
||||
let (_, markup, css) = web(doc, c, CodeLang::Vue);
|
||||
// A slot layer is a `<slot>` element in the template, so it takes no prop.
|
||||
let props: Vec<&Param> = c.params.iter().filter(|p| !matches!(p.kind, ParamKind::Slot)).collect();
|
||||
let mut out = String::new();
|
||||
if !props.is_empty() {
|
||||
// `__props` can't be a generated prop name (idents are camelCase words), so a property
|
||||
// named `Props` isn't shadowed by the binding.
|
||||
out.push_str("<script setup lang=\"ts\">\n\nconst __props = withDefaults(\n defineProps<{\n");
|
||||
for p in &props {
|
||||
let ty = match &p.kind {
|
||||
ParamKind::Variant(opts) => opts.iter().map(|o| vue_string(o)).collect::<Vec<_>>().join(" | "),
|
||||
ParamKind::Boolean => "boolean".into(),
|
||||
ParamKind::Text => "string".into(),
|
||||
ParamKind::InstanceSwap | ParamKind::Slot => "unknown".into(),
|
||||
};
|
||||
out.push_str(&format!(" {}?: {ty};\n", p.ident));
|
||||
}
|
||||
out.push_str(" }>(),\n {\n");
|
||||
for p in &props {
|
||||
// An instance swap defaults to its component reference; a slot isn't in `props`.
|
||||
out.push_str(&format!(" {}: {},\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::Vue)));
|
||||
}
|
||||
out.push_str(" },\n);\n\n</script>\n\n");
|
||||
}
|
||||
out.push_str(&format!("<template>\n{markup}</template>\n\n<style scoped>\n{css}</style>\n"));
|
||||
out
|
||||
}
|
||||
|
||||
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
|
||||
/// and its stylesheet.
|
||||
fn leptos(doc: &Document, c: &Component) -> String {
|
||||
|
||||
@@ -149,6 +149,17 @@ fn instances_are_calls_passing_the_values_they_set() {
|
||||
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, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
|
||||
// keeps its JS quotes, and the attribute's own quotes are entities.
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" :title=\""Say \\"hi\\" $5\\nnow"\" />\n");
|
||||
let vue = code(&mut s, i, "vue");
|
||||
assert!(vue.starts_with("<Card :showDot=\"false\" :title=\""Say \\"hi\\" $5\\nnow"\" />\n"), "{vue}");
|
||||
// A plain attribute keeps HTML entities intact: a literal ampersand is written as `&`,
|
||||
// and a literal `&` as `&amp;`.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}}));
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" title=\"Fish & Chips\" />\n");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}}));
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" title=\"Fish &amp; Chips\" />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -252,6 +263,10 @@ fn instance_swap_properties_take_views() {
|
||||
let leptos = code(&mut s, chip, "leptos");
|
||||
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
|
||||
assert!(leptos.contains("{icon.run()}"), "{leptos}");
|
||||
let vue = code(&mut s, chip, "vue");
|
||||
assert!(vue.contains(" icon?: unknown;"), "{vue}");
|
||||
assert!(vue.contains(" icon: HeartIcon,"), "the swap defaults to its component: {vue}");
|
||||
assert!(vue.contains("<component :is=\"icon\" />"), "a swap is a component, not an element: {vue}");
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
|
||||
@@ -259,6 +274,7 @@ fn instance_swap_properties_take_views() {
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Chip :icon=\"StarIcon\" />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2009,6 +2009,8 @@ mod version_tests;
|
||||
#[cfg(test)]
|
||||
mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vue_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod widget_input_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"component.playground",
|
||||
"Component playground",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "component.playground";
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
|
||||
};
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose|arkui"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let (copy, inst) = build(s.doc()?, target, &values)?;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Vue code: `inspect.code {language: "vue"}` writes a single-file component — `<template>`, a
|
||||
//! `<script setup>` with typed props, and a `<style scoped>` — for a layer tree, and a component
|
||||
//! with its properties as props, its variants as `data-*` attributes and its slots as
|
||||
//! `<slot>` elements. A Vue template holds no arbitrary expressions, so what React writes as a
|
||||
//! braced expression (an interpolation, a `v-if`, a `:prop` binding) has its own spelling here.
|
||||
|
||||
use crate::Session;
|
||||
use crate::codegen::CodeLang;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use figcraft_doc::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
|
||||
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
fn vue(s: &mut Session, id: NodeId) -> String {
|
||||
code(s, id, "vue")
|
||||
}
|
||||
|
||||
fn component_code(s: &mut Session, id: NodeId, lang: &str) -> serde_json::Value {
|
||||
ex(s, "inspect.componentCode", json!({"id": id, "language": lang}))
|
||||
}
|
||||
|
||||
/// A "Card" component with a "Title" text (text property) and a "Dot" ellipse (boolean
|
||||
/// property "Show dot"). Returns (card, title, dot).
|
||||
fn card(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}));
|
||||
let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
||||
let dot = idv(&ex(s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f, "props": {"name": "Dot"}})));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
ex(s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
|
||||
ex(s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
|
||||
(c, title, dot)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_tree_becomes_a_single_file_component() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
|
||||
ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
|
||||
let out = vue(&mut s, f);
|
||||
// The three blocks of an SFC, in order, with the tree in the template and the CSS scoped.
|
||||
assert!(out.starts_with("<template>\n"), "{out}");
|
||||
assert!(out.contains("</template>\n\n<style scoped>\n"), "{out}");
|
||||
assert!(out.ends_with("</style>\n"), "{out}");
|
||||
assert!(out.contains("<div class=\"panel\">"), "{out}");
|
||||
assert!(out.contains(">Hi</p>"), "{out}");
|
||||
// A tree without properties needs no script block.
|
||||
assert!(!out.contains("<script"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_take_their_properties_as_props() {
|
||||
let mut s = Session::new();
|
||||
let (c, _, _) = card(&mut s);
|
||||
let out = vue(&mut s, c);
|
||||
// Typed props with their defaults, and the template below.
|
||||
assert!(out.starts_with("<script setup lang=\"ts\">\n"), "{out}");
|
||||
assert!(out.contains("withDefaults(\n defineProps<{\n"), "{out}");
|
||||
assert!(out.contains("showDot?: boolean;"), "{out}");
|
||||
assert!(out.contains("title?: string;"), "{out}");
|
||||
assert!(out.contains("showDot: true,"), "{out}");
|
||||
assert!(out.contains("title: \"Hello\","), "{out}");
|
||||
assert!(out.contains("</script>\n\n<template>\n"), "{out}");
|
||||
// A text property is an interpolation, not React's braced expression.
|
||||
assert!(out.contains("{{ title }}"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boolean_property_guards_its_layer_with_v_if() {
|
||||
let mut s = Session::new();
|
||||
let (c, _, _) = card(&mut s);
|
||||
let out = vue(&mut s, c);
|
||||
// Vue can't wrap an element in an expression: the guard rides on the element itself.
|
||||
assert!(out.contains("v-if=\"showDot\""), "{out}");
|
||||
assert!(!out.contains("showDot &&"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_switch_on_data_attributes() {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() {
|
||||
let f =
|
||||
idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")})));
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}}));
|
||||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
|
||||
let out = vue(&mut s, set);
|
||||
// A variant property is a string union, bound to the root's data attribute.
|
||||
assert!(out.contains("state?: \"Default\" | \"Hover\";"), "{out}");
|
||||
assert!(out.contains(":data-state=\"state\""), "{out}");
|
||||
// What the other variant changes is a rule on that attribute, as on the web.
|
||||
assert!(out.contains("[data-state=\"Hover\"]"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_are_calls_passing_the_values_they_set() {
|
||||
let mut s = Session::new();
|
||||
let (c, _, _) = card(&mut s);
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
|
||||
// Values left at the definition's default aren't passed.
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card />\n");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
|
||||
// A plain string is an attribute value; anything else is a `:name` binding.
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" title=\"Buy\" />\n");
|
||||
let out = code(&mut s, i, "vue");
|
||||
assert!(out.starts_with("<Card :showDot=\"false\" title=\"Buy\" />\n"), "{out}");
|
||||
assert!(out.contains("defineProps<{"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_tree_with_an_instance_calls_it() {
|
||||
let mut s = Session::new();
|
||||
let (c, _, _) = card(&mut s);
|
||||
let parent = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 300, "name": "Page"})));
|
||||
ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 10, "parent": parent}));
|
||||
let out = vue(&mut s, parent);
|
||||
assert!(out.contains("<Card "), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_become_slot_elements() {
|
||||
let mut s = Session::new();
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Shell"})));
|
||||
let body = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": frame, "name": "Body"})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": body, "name": "Content"}));
|
||||
let out = vue(&mut s, c);
|
||||
// An empty slot is Vue's own element, and a slot takes no prop.
|
||||
assert!(out.contains("<slot name=\"content\" />"), "{out}");
|
||||
assert!(!out.contains("content?: "), "{out}");
|
||||
assert!(!out.contains("props"), "{out}");
|
||||
|
||||
// Content in the slot is its fallback, shown until an instance fills it.
|
||||
let mut s = Session::new();
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Shell2"})));
|
||||
let body = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": frame, "name": "Body2"})));
|
||||
ex(&mut s, "text.create", json!({"text": "Empty", "x": 0, "y": 0, "parent": body, "props": {"name": "Hint"}}));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": body, "name": "Content"}));
|
||||
let out = vue(&mut s, c);
|
||||
assert!(out.contains("<slot name=\"content\">"), "{out}");
|
||||
assert!(out.contains("</slot>"), "{out}");
|
||||
assert!(out.contains("Empty"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_are_escaped_and_the_language_round_trips() {
|
||||
let mut s = Session::new();
|
||||
let (c, _, _) = card(&mut s);
|
||||
// "Show dot" is not an identifier: it becomes camelCase in the props and the template alike.
|
||||
let out = vue(&mut s, c);
|
||||
assert!(out.contains("showDot"), "{out}");
|
||||
assert!(!out.contains("Show dot"), "{out}");
|
||||
// The id is discoverable from what the Inspect panel offers.
|
||||
assert_eq!(CodeLang::from_id("vue"), Some(CodeLang::Vue));
|
||||
assert_eq!(CodeLang::from_id("sfc"), Some(CodeLang::Vue));
|
||||
assert!(CodeLang::Vue.is_web());
|
||||
assert_eq!(CodeLang::Vue.id(), "vue");
|
||||
assert!(CodeLang::ALL.contains(&CodeLang::Vue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_converts_lengths_in_the_vue_template_and_its_style() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 64, "name": "Bar"})));
|
||||
let out = ex(&mut s, "inspect.code", json!({"id": f, "language": "vue", "unit": "rem"}))["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(out.contains("20rem"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyword_property_name_stays_usable() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Row"})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
// "default" is a JavaScript keyword: the prop is renamed, and the template follows.
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "default", "type": "text", "default": "x"}));
|
||||
let out = vue(&mut s, c);
|
||||
assert!(out.contains("defaultValue"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_named_props_is_not_shadowed() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let text = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Props", "type": "text", "layer": text}));
|
||||
let out = vue(&mut s, c);
|
||||
// No top-level `const props` that the template would resolve to instead of the property.
|
||||
assert!(!out.contains("const props"), "{out}");
|
||||
assert!(out.contains("props?: string;"), "{out}");
|
||||
assert!(out.contains("{{ props }}"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_instance_keeps_its_v_if_guard() {
|
||||
let mut s = Session::new();
|
||||
let (card, _, _) = card(&mut s);
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Toolbar"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 10, "y": 10, "parent": f})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show card", "type": "boolean", "default": true, "layer": nested}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Title": "a > b"}}));
|
||||
let out = vue(&mut s, c);
|
||||
// The call's early return must not swallow the guard `node` put on it, and the `>` inside a
|
||||
// quoted value must not be mistaken for the tag's end.
|
||||
assert!(out.contains("v-if=\"showCard\""), "{out}");
|
||||
assert_eq!(out.matches("v-if=\"showCard\"").count(), 1, "{out}");
|
||||
assert!(!out.contains("/ v-if"), "the guard must come before a self-closing slash: {out}");
|
||||
assert!(out.contains("title=\"a > b\"") || out.contains("title=\"a > b\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_default_cannot_close_the_script_block() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let text = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(
|
||||
&mut s,
|
||||
"component.addProperty",
|
||||
json!({"id": c, "name": "Title", "type": "text", "layer": text, "default": "</script><script>alert(1)</script>"}),
|
||||
);
|
||||
let out = vue(&mut s, c);
|
||||
assert!(out.contains("\\u003C/script>"), "{out}");
|
||||
assert_eq!(out.matches("</script>").count(), 1, "only the SFC's own closing tag: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_names_stay_distinct_when_idents_lowercase_alike() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Shell"})));
|
||||
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": f, "name": "A"})));
|
||||
let b = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100, "parent": f, "name": "B"})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": a, "name": "Foo Bar"}));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": b, "name": "Foobar"}));
|
||||
let out = vue(&mut s, c);
|
||||
assert!(out.contains("<slot name=\"foobar\""), "{out}");
|
||||
assert!(out.contains("<slot name=\"foobar-2\""), "{out}");
|
||||
// The instance's templates use the same names.
|
||||
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 20, "height": 20, "name": "Badge"})));
|
||||
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
|
||||
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Foo Bar", "component": badge, "replace": true}));
|
||||
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Foobar", "component": badge, "replace": true}));
|
||||
let usage = component_code(&mut s, i, "vue")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.contains("<template #foobar>"), "{usage}");
|
||||
assert!(usage.contains("<template #foobar-2>"), "{usage}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_content_css_reaches_the_parent_stylesheet() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
|
||||
let content = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "name": "Content"})));
|
||||
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": content, "name": "Content"}));
|
||||
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 400, "height": 300, "name": "Screen"})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 10, "y": 10, "parent": screen})));
|
||||
let slot = {
|
||||
let doc = s.doc().unwrap();
|
||||
crate::components::slots_of(doc, i).first().map(|(l, _)| *l).unwrap()
|
||||
};
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": slot, "props": {"name": "Swatch", "fill": "#FF0000"}}));
|
||||
// The layer's class rules belong to the tree that holds the instance, not to a discarded
|
||||
// snippet: both web targets keep them in their stylesheet.
|
||||
let out = vue(&mut s, screen);
|
||||
assert!(out.contains(".slot-content-swatch {"), "{out}");
|
||||
assert!(out.contains("#FF0000"), "{out}");
|
||||
assert!(code(&mut s, screen, "react").contains(".slot-content-swatch {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_content_classes_are_unique_across_slots() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
|
||||
let one = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": f, "name": "One"})));
|
||||
let two = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100, "parent": f, "name": "Two"})));
|
||||
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": one, "name": "One"}));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": two, "name": "Two"}));
|
||||
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 400, "height": 300, "name": "Screen"})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 10, "y": 10, "parent": screen})));
|
||||
let slots = {
|
||||
let doc = s.doc().unwrap();
|
||||
crate::components::slots_of(doc, i)
|
||||
};
|
||||
for (name, fill) in [("One", "#FF0000"), ("Two", "#0000FF")] {
|
||||
let slot = slots.iter().find(|(_, n)| n.as_str() == name).map(|(l, _)| *l).unwrap();
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": slot, "props": {"name": "Swatch", "fill": fill}}));
|
||||
}
|
||||
let out = vue(&mut s, screen);
|
||||
// Same layer name in two slots: each slot's rules keep their own class.
|
||||
assert!(out.contains(".slot-one-swatch {"), "{out}");
|
||||
assert!(out.contains(".slot-two-swatch {"), "{out}");
|
||||
assert!(out.contains("#FF0000"), "{out}");
|
||||
assert!(out.contains("#0000FF"), "{out}");
|
||||
}
|
||||
@@ -245,7 +245,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "প্রধান (main)-এ পরিবর্তন"
|
||||
"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন"
|
||||
"Changes unknown" = "পরিবর্তন অজ্ঞাত"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI, Compose বা ArkUI বেছে নিন।"
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, Vue, SwiftUI, Compose বা ArkUI বেছে নিন।"
|
||||
"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।"
|
||||
"Choose collection" = "সংগ্রহ (collection) বেছে নিন"
|
||||
"Choose image" = "ছবি বেছে নিন"
|
||||
|
||||
@@ -235,7 +235,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Změny v hlavním souboru"
|
||||
"Changes on the branch" = "Změny ve větvi"
|
||||
"Changes unknown" = "Změny nejsou známy"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, SwiftUI, Compose nebo ArkUI."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, Vue, SwiftUI, Compose nebo ArkUI."
|
||||
"Choose another profile to change it." = "Pro změnu vyberte jiný profil."
|
||||
"Choose collection" = "Vybrat kolekci"
|
||||
"Choose image" = "Vybrat obrázek"
|
||||
|
||||
@@ -230,7 +230,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Änderungen in der Hauptdatei"
|
||||
"Changes on the branch" = "Änderungen im Branch"
|
||||
"Changes unknown" = "Änderungen unbekannt"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI, Compose oder ArkUI, um diese Komponente mit ihrem Code zu verbinden."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wählen Sie oben React, Leptos, Vue, SwiftUI, Compose oder ArkUI, um diese Komponente mit ihrem Code zu verbinden."
|
||||
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
|
||||
"Choose collection" = "Sammlung wählen"
|
||||
"Choose image" = "Bild wählen"
|
||||
|
||||
@@ -248,7 +248,7 @@ name = "English"
|
||||
"Changes on main" = "Changes on main"
|
||||
"Changes on the branch" = "Changes on the branch"
|
||||
"Changes unknown" = "Changes unknown"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code."
|
||||
"Choose another profile to change it." = "Choose another profile to change it."
|
||||
"Choose collection" = "Choose collection"
|
||||
"Choose image" = "Choose image"
|
||||
|
||||
@@ -247,7 +247,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Cambios en la principal"
|
||||
"Changes on the branch" = "Cambios en la rama"
|
||||
"Changes unknown" = "Cambios desconocidos"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Elige React, Leptos, SwiftUI, Compose o ArkUI arriba para conectar este componente con su código."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Elige React, Leptos, Vue, SwiftUI, Compose o ArkUI arriba para conectar este componente con su código."
|
||||
"Choose another profile to change it." = "Elige otro perfil para cambiarlo."
|
||||
"Choose collection" = "Elegir colección"
|
||||
"Choose image" = "Elegir imagen"
|
||||
|
||||
@@ -236,7 +236,7 @@ status = "draft — needs native review"
|
||||
"Check" = "Valintamerkki"
|
||||
"Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…"
|
||||
"Checking the server…" = "Tarkistetaan palvelinta…"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI, Compose tai ArkUI yhdistääksesi tämän komponentin sen koodiin."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Valitse yllä React, Leptos, Vue, SwiftUI, Compose tai ArkUI yhdistääksesi tämän komponentin sen koodiin."
|
||||
"Chevron" = "Kulmanuoli"
|
||||
"Choose another profile to change it." = "Vaihda valitsemalla toinen profiili."
|
||||
"Choose collection" = "Valitse kokoelma"
|
||||
|
||||
@@ -239,7 +239,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Modifications dans le fichier principal"
|
||||
"Changes on the branch" = "Modifications dans la branche"
|
||||
"Changes unknown" = "Modifications inconnues"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choisissez React, Leptos, SwiftUI, Compose ou ArkUI ci-dessus pour relier ce composant à son code."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choisissez React, Leptos, Vue, SwiftUI, Compose ou ArkUI ci-dessus pour relier ce composant à son code."
|
||||
"Choose another profile to change it." = "Choisissez un autre profil pour le changer."
|
||||
"Choose collection" = "Choisir une collection"
|
||||
"Choose image" = "Choisir une image"
|
||||
|
||||
@@ -239,7 +239,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Perubahan di main"
|
||||
"Changes on the branch" = "Perubahan di branch"
|
||||
"Changes unknown" = "Perubahan tidak diketahui"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pilih React, Leptos, SwiftUI, Compose, atau ArkUI di atas untuk menghubungkan component ini ke kodenya."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pilih React, Leptos, Vue, SwiftUI, Compose, atau ArkUI di atas untuk menghubungkan component ini ke kodenya."
|
||||
"Choose another profile to change it." = "Pilih profil lain untuk mengubahnya."
|
||||
"Choose collection" = "Pilih koleksi"
|
||||
"Choose image" = "Pilih gambar"
|
||||
|
||||
@@ -228,7 +228,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Modifiche su main"
|
||||
"Changes on the branch" = "Modifiche sul ramo"
|
||||
"Changes unknown" = "Modifiche sconosciute"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Scegli React, Leptos, SwiftUI, Compose o ArkUI qui sopra per collegare questo componente al suo codice."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Scegli React, Leptos, Vue, SwiftUI, Compose o ArkUI qui sopra per collegare questo componente al suo codice."
|
||||
"Choose another profile to change it." = "Scegli un altro profilo per cambiarlo."
|
||||
"Choose collection" = "Scegli collezione"
|
||||
"Choose image" = "Scegli immagine"
|
||||
|
||||
@@ -240,7 +240,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "メインの変更"
|
||||
"Changes on the branch" = "ブランチの変更"
|
||||
"Changes unknown" = "変更内容は不明"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "上で React、Leptos、SwiftUI、Compose、ArkUI のいずれかを選び、このコンポーネントをコードに接続。"
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "上で React、Leptos、Vue、SwiftUI、Compose、ArkUI のいずれかを選び、このコンポーネントをコードに接続。"
|
||||
"Choose another profile to change it." = "変更するには別のプロファイルを選択。"
|
||||
"Choose collection" = "コレクションを選択"
|
||||
"Choose image" = "画像を選択"
|
||||
|
||||
@@ -247,7 +247,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Changes on main" = "ცვლილებები მთავარში"
|
||||
"Changes on the branch" = "ცვლილებები განშტოებაში"
|
||||
"Changes unknown" = "ცვლილებები უცნობია"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, SwiftUI, Compose ან ArkUI."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, Vue, SwiftUI, Compose ან ArkUI."
|
||||
"Choose another profile to change it." = "მის შესაცვლელად აირჩიეთ სხვა პროფილი."
|
||||
"Choose collection" = "კოლექციის არჩევა"
|
||||
"Choose image" = "გამოსახულების არჩევა"
|
||||
|
||||
@@ -230,7 +230,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "메인의 변경 사항"
|
||||
"Changes on the branch" = "브랜치의 변경 사항"
|
||||
"Changes unknown" = "변경 내용 알 수 없음"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "위에서 React, Leptos, SwiftUI, Compose 또는 ArkUI를 선택해 이 컴포넌트를 코드에 연결하세요."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "위에서 React, Leptos, Vue, SwiftUI, Compose 또는 ArkUI를 선택해 이 컴포넌트를 코드에 연결하세요."
|
||||
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
|
||||
"Choose collection" = "컬렉션 선택"
|
||||
"Choose image" = "이미지 선택"
|
||||
|
||||
@@ -236,7 +236,7 @@ status = "draft — needs native review"
|
||||
"Check" = "Vinkje"
|
||||
"Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…"
|
||||
"Checking the server…" = "Server controleren…"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI, Compose of ArkUI om dit component aan zijn code te koppelen."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Kies hierboven React, Leptos, Vue, SwiftUI, Compose of ArkUI om dit component aan zijn code te koppelen."
|
||||
"Chevron" = "Punthaak"
|
||||
"Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen."
|
||||
"Choose collection" = "Kies een verzameling"
|
||||
|
||||
@@ -245,7 +245,7 @@ name = "Polski"
|
||||
"Changes on main" = "Zmiany w gałęzi głównej"
|
||||
"Changes on the branch" = "Zmiany w gałęzi"
|
||||
"Changes unknown" = "Nieznane zmiany"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wybierz React, Leptos, SwiftUI, Compose lub ArkUI powyżej, aby połączyć ten komponent z jego kodem."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wybierz React, Leptos, Vue, SwiftUI, Compose lub ArkUI powyżej, aby połączyć ten komponent z jego kodem."
|
||||
"Choose another profile to change it." = "Wybierz inny profil, aby go zmienić."
|
||||
"Choose collection" = "Wybierz kolekcję"
|
||||
"Choose image" = "Wybierz obraz"
|
||||
|
||||
@@ -228,7 +228,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Alterações no principal"
|
||||
"Changes on the branch" = "Alterações na branch"
|
||||
"Changes unknown" = "Alterações desconhecidas"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Escolha React, Leptos, SwiftUI, Compose ou ArkUI acima para conectar este componente ao código dele."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Escolha React, Leptos, Vue, SwiftUI, Compose ou ArkUI acima para conectar este componente ao código dele."
|
||||
"Choose another profile to change it." = "Escolha outro perfil para alterá-lo."
|
||||
"Choose collection" = "Escolher coleção"
|
||||
"Choose image" = "Escolher imagem"
|
||||
|
||||
@@ -242,7 +242,7 @@ name = "Русский"
|
||||
"Changes on main" = "Изменения в основной ветке"
|
||||
"Changes on the branch" = "Изменения в ветке"
|
||||
"Changes unknown" = "Изменения неизвестны"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Выберите выше React, Leptos, SwiftUI, Compose или ArkUI, чтобы связать компонент с его кодом."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Выберите выше React, Leptos, Vue, SwiftUI, Compose или ArkUI, чтобы связать компонент с его кодом."
|
||||
"Choose another profile to change it." = "Для смены выберите другой профиль."
|
||||
"Choose collection" = "Выбрать коллекцию"
|
||||
"Choose image" = "Выбрать изображение"
|
||||
|
||||
@@ -242,7 +242,7 @@ name = "Svenska"
|
||||
"Changes on main" = "Ändringar i main"
|
||||
"Changes on the branch" = "Ändringar i grenen"
|
||||
"Changes unknown" = "Okända ändringar"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Välj React, Leptos, SwiftUI, Compose eller ArkUI ovan för att koppla den här komponenten till dess kod."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Välj React, Leptos, Vue, SwiftUI, Compose eller ArkUI ovan för att koppla den här komponenten till dess kod."
|
||||
"Choose another profile to change it." = "Välj en annan profil för att ändra den."
|
||||
"Choose collection" = "Välj samling"
|
||||
"Choose image" = "Välj bild"
|
||||
|
||||
@@ -243,7 +243,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "การเปลี่ยนแปลงบนเมน"
|
||||
"Changes on the branch" = "การเปลี่ยนแปลงบนแบรนช์"
|
||||
"Changes unknown" = "ไม่ทราบการเปลี่ยนแปลง"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "เลือก React, Leptos, SwiftUI, Compose หรือ ArkUI ด้านบนเพื่อเชื่อมคอมโพเนนต์นี้กับโค้ด"
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "เลือก React, Leptos, Vue, SwiftUI, Compose หรือ ArkUI ด้านบนเพื่อเชื่อมคอมโพเนนต์นี้กับโค้ด"
|
||||
"Choose another profile to change it." = "เลือกโปรไฟล์อื่นเพื่อเปลี่ยน"
|
||||
"Choose collection" = "เลือกคอลเลกชัน"
|
||||
"Choose image" = "เลือกรูปภาพ"
|
||||
|
||||
@@ -239,7 +239,7 @@ name = "Türkçe"
|
||||
"Changes on main" = "Ana dosyadaki değişiklikler"
|
||||
"Changes on the branch" = "Daldaki değişiklikler"
|
||||
"Changes unknown" = "Değişiklikler bilinmiyor"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, SwiftUI, Compose veya ArkUI seçin."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, Vue, SwiftUI, Compose veya ArkUI seçin."
|
||||
"Choose another profile to change it." = "Değiştirmek için başka bir profil seçin."
|
||||
"Choose collection" = "Koleksiyon seç"
|
||||
"Choose image" = "Görüntü seç"
|
||||
|
||||
@@ -223,7 +223,7 @@ status = "draft — needs native review"
|
||||
"Changes unknown" = "Không rõ thay đổi"
|
||||
"Checking the fonts installed on this computer…" = "Đang kiểm tra các phông chữ cài đặt trên máy tính này…"
|
||||
"Checking the server…" = "Đang kiểm tra máy chủ…"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Hãy chọn React, Leptos, SwiftUI, Compose hoặc ArkUI ở trên để kết nối thành phần này với mã nguồn."
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Hãy chọn React, Leptos, Vue, SwiftUI, Compose hoặc ArkUI ở trên để kết nối thành phần này với mã nguồn."
|
||||
"Choose another profile to change it." = "Hãy chọn hồ sơ khác để thay đổi."
|
||||
"Choose collection" = "Chọn bộ sưu tập"
|
||||
"Choose image" = "Chọn hình ảnh"
|
||||
|
||||
@@ -231,7 +231,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "主文件上的更改"
|
||||
"Changes on the branch" = "分支上的更改"
|
||||
"Changes unknown" = "更改未知"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "在上方选择 React、Leptos、SwiftUI、Compose 或 ArkUI,将此组件关联到其代码。"
|
||||
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "在上方选择 React、Leptos、Vue、SwiftUI、Compose 或 ArkUI,将此组件关联到其代码。"
|
||||
"Choose another profile to change it." = "选择其他配置文件以更改。"
|
||||
"Choose collection" = "选择集合"
|
||||
"Choose image" = "选择图片"
|
||||
|
||||
@@ -445,7 +445,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
|
||||
let text = if connectable {
|
||||
tr("Link this component to its code component: instances then show your real snippet with their values.")
|
||||
} else {
|
||||
tr("Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code.")
|
||||
tr("Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code.")
|
||||
};
|
||||
widgets::note(ui, text, t.text_secondary);
|
||||
return;
|
||||
|
||||
@@ -12,13 +12,13 @@ repository, third-party integrations. Remaining: 1–2 h.
|
||||
|---|---|---|
|
||||
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
|
||||
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
|
||||
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
|
||||
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
|
||||
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
|
||||
| Focus view (one frame, previous / next) | done | |
|
||||
| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions |
|
||||
| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository |
|
||||
| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository |
|
||||
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations |
|
||||
| Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back |
|
||||
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
|
||||
|
||||
@@ -56,7 +56,7 @@ export limit, not a native document nesting limit.
|
||||
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
|
||||
| WebP | — | yes (ours) | | — |
|
||||
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
|
||||
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — |
|
||||
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React, Leptos and Vue | see [dev-mode-parity](dev-mode-parity.md) | — |
|
||||
| `.fig` ("Save local copy") | yes | **experimental** | the whole file, also as a copy from File ▸ Save as… (the document keeps its `.figcraft` path and unsaved state); see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) |
|
||||
|
||||
### Export to `.fig` (experimental)
|
||||
|
||||
+1
-1
@@ -372,7 +372,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
### 16. Dev Mode: the tail after dev resources
|
||||
- **Landed (2026-10-11):** code connect: a component or set links to its code component per
|
||||
language (React, SwiftUI, Compose, ArkUI): an import path, the name and a template whose `{property}`
|
||||
language (React, Leptos, Vue, SwiftUI, Compose, ArkUI): an import path, the name and a template whose `{property}`
|
||||
and `{property ? "a" : "b"}` placeholders take the instance's values, escaped per language and
|
||||
never executed. `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet
|
||||
instead of generated code (inside generated layer trees too); the panel's Connect to code edits
|
||||
|
||||
+21
-9
@@ -308,7 +308,7 @@ Or in a project's `.mcp.json`:
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI, Jetpack Compose or ArkUI for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose or ArkUI for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
@@ -1493,19 +1493,25 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
||||
same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the
|
||||
desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex`
|
||||
(auto layout frames) or `lg:block` (other layers).
|
||||
- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose|arkui}` on a component,
|
||||
- **Component code:** `inspect.code {id, language: react|leptos|vue|swiftui|compose|arkui}` on a component,
|
||||
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
||||
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
|
||||
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), Vue as a
|
||||
single-file component (`<template>`, a `<script setup>` with `defineProps` and its defaults, a
|
||||
`<style scoped>`; a slot layer is Vue's own `<slot>`, so it takes no prop), SwiftUI
|
||||
with a `View` struct, Compose with a `@Composable` function, ArkUI with a `@Component` ArkTS
|
||||
struct (`@Prop` parameters, `@BuilderParam` for swaps and slots). Its component properties are the
|
||||
parameters, with their defaults: variant properties become string unions (React) or enums,
|
||||
parameters, with their defaults: variant properties become string unions (React, Vue) or enums,
|
||||
booleans become flags around the layers they show, text properties strings, instance swaps views
|
||||
(`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda, an ArkUI builder) and slots
|
||||
optional content.
|
||||
Every variant of a set shares one definition; in React and Leptos the other variants'
|
||||
differences become CSS rules on `data-*` attributes. On an instance (or a layer tree containing
|
||||
Every variant of a set shares one definition; in React, Vue and Leptos the other variants'
|
||||
differences become CSS rules on `data-*` attributes. A Vue template holds no arbitrary
|
||||
expressions, so what React writes as a braced expression has its own spelling there: text is
|
||||
`{{ prop }}`, a boolean property's layer carries `v-if="prop"`, and a binding is
|
||||
`:prop="value"`. On an instance (or a layer tree containing
|
||||
instances) each instance is a call passing only the values it changes, e.g. `<Button
|
||||
state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`, `Button(state:
|
||||
state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`,
|
||||
`<Button state="Hover" />`, `Button(state:
|
||||
.hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = "Buy")`, `Button({ state:
|
||||
ButtonState.Hover, label: "Buy" })`. Names are escaped and
|
||||
unique (keywords get a suffix, `default` → `defaultValue`, Rust's `type` → `type_`; Leptos names
|
||||
@@ -1513,12 +1519,18 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
||||
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
|
||||
default, value? (instances)}], definition, usage? (instances)}`.
|
||||
- **Code connect:** link a component (a variant links its set) to the code component it is built
|
||||
as, per language (`react`, `leptos`, `swiftui`, `compose`, `arkui`): `codeConnect.set {id?, language?, importPath?,
|
||||
as, per language (`react`, `leptos`, `vue`, `swiftui`, `compose`, `arkui`): `codeConnect.set {id?, language?, importPath?,
|
||||
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
|
||||
name and a call passing every property) → `{component, language, code, unknown}`. Templates are
|
||||
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
|
||||
for the language (React and Leptos keep the markup's braces, so `size={size}` gives
|
||||
`size="Large"` and `disabled={disabled}` gives `disabled={true}`; Leptos imports with `use
|
||||
`size="Large"` and `disabled={disabled}` gives `disabled={true}`; a Vue binding takes an
|
||||
expression, so `:size={size}` gives `:size=""Large""` and `:disabled={disabled}` gives
|
||||
`:disabled="true"` — the string keeps its JS quotes as entities, and a static attribute
|
||||
(`size={size}`) takes plain text; a
|
||||
component swap is the component itself, `:icon={icon}` giving `:icon="Icon"`; a string that needs
|
||||
escapes keeps them inside the value, where the attribute's own quotes become `"`; Leptos
|
||||
imports with `use
|
||||
<importPath>::<name>;`), `{property ? "a" : "b"}` inserts one of two
|
||||
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
|
||||
properties). Then `inspect.code` on an instance or the component returns the import line and the
|
||||
|
||||
@@ -623,7 +623,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
toolbar with flyouts and mode switcher, Design / Prototype / Inspect panels (Dev Mode: properties with their styles and variables, annotations
|
||||
with pinned properties and categories drawn as coloured callouts on the canvas (filtered by category),
|
||||
a ready-for-dev list, a focus view on one frame with previous / next, Compare changes since a frame
|
||||
was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/SwiftUI/Compose/ArkUI
|
||||
was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/Vue/SwiftUI/Compose/ArkUI
|
||||
code where components take their properties as parameters and instances are calls, components
|
||||
connected to their code components showing the real snippet, read-only plugins), colour picker
|
||||
(HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog,
|
||||
|
||||
Reference in New Issue
Block a user