M6: fix Vue bindings, instance guards and slot catalogs

Vue connected templates keep a string's JS quotes inside bound attributes, so `:title={title}` becomes `:title="&quot;Buy&quot;"` rather than a bare identifier; static attributes take plain text. Nested instances no longer lose a boolean `v-if` guard, instance swaps render through `<component :is>`, slot names stay distinct when their idents lowercase alike, slot content CSS reaches the parent stylesheet, and text defaults escape `</script>` in the script block. The five locale catalogs the Vue key rename missed (bn, fi, ka, nl, pl) are updated, and the docs list Leptos/Vue consistently.
This commit is contained in:
phonedu
2026-10-22 19:52:58 +08:00
parent 7c211d8b0d
commit a132eb3768
14 changed files with 252 additions and 53 deletions
+59 -9
View File
@@ -184,6 +184,38 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
}
}
/// Whether the placeholder sits in a Vue bound attribute (`:name`, `v-bind:name`, `@event` or a
/// `v-*` directive) rather than a static one. `None` when it isn't in an attribute at all.
fn vue_bound_attribute(out: &str) -> Option<bool> {
let end = out.trim_end();
let after = end.strip_suffix('"').unwrap_or(end).trim_end();
let eq = after.rfind('=')?;
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;
}
Some(name.starts_with(':') || name.starts_with("v-bind:") || name.starts_with('@') || name.starts_with("v-on:") || name.starts_with("v-"))
}
/// An expression written into a double-quoted HTML attribute: its own quotes and markup become
/// entities, so Vue's parser hands the expression its string literal intact.
fn html_attr_expr(e: &str) -> String {
e.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;")
}
/// Literal text written into a double-quoted HTML attribute.
fn html_attr_text(s: &str) -> String {
s.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;")
}
/// `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 {
// A template that skipped `check` (a hand-edited file) and is far too long stays as written:
@@ -199,16 +231,34 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)],
let e = expr(doc, c, p, v, lang);
// JSX and `view!`: a plain string reads best as an attribute value, anything
// else is an expression in braces (JSX attribute strings have no escapes).
// A Vue binding takes a quoted value. A plain string literal brings its own
// quotes; the template's own quote, when it wrote one, ends it; anything else
// is wrapped here, and a quote inside is escaped for the attribute.
// 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 {
if !out.ends_with('"') && e.starts_with('"') && !e.contains('\\') {
out.push_str(&e);
} else if out.ends_with('"') {
out.push_str(&e.replace('"', "&quot;"));
} else {
out.push_str(&format!("\"{}\"", e.replace('"', "&quot;")));
match vue_bound_attribute(&out) {
Some(true) => {
let expr = html_attr_expr(&e);
if out.ends_with('"') {
out.push_str(&expr);
} else {
out.push('"');
out.push_str(&expr);
out.push('"');
}
}
Some(false) => {
let text = match &p.kind {
ParamKind::Text | ParamKind::Variant(_) => html_attr_text(v.as_str().unwrap_or("")),
_ => e.clone(),
};
if out.ends_with('"') {
out.push_str(&text);
} else {
out.push('"');
out.push_str(&text);
out.push('"');
}
}
None => out.push_str(&e),
}
} else {
let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
+17 -6
View File
@@ -190,29 +190,40 @@ fn vue_links_are_a_default_import_with_quoted_bindings() {
let c = card(&mut s);
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
// A single-file component is a default import, and a binding's value is quoted (the template
// writes `:prop={prop}`, so a plain string brings its own quotes).
// 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";
let want = "import Card from \"@/components/Card.vue\";\n\n<Card :title=\"&quot;Buy&quot;\" :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");
assert_eq!(code(&mut s, c, "vue"), "import Card from \"./Card.vue\";\n\n<Card :showDot=\"true\" :title=\"&quot;Hello&quot;\" />\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=\"&quot;Hover&quot;\" />\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("&quot;")).unwrap_or_default();
assert!(line.starts_with("<Card :showDot=\"false\""), "{line}");
assert!(line.contains(":title=\"&quot;Say"), "{line}");
assert!(line.starts_with("<Card :title=\"&quot;Say"), "{line}");
assert!(line.contains(":dotted=\"false\""), "{line}");
assert!(line.ends_with("\" />"), "{line}");
// Vue is linkable; a language that isn't stays refused.
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "css"})).is_err(), "not a code language");
+29 -11
View File
@@ -243,12 +243,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)
}
}
@@ -619,6 +620,15 @@ pub(crate) fn class_name(name: &str, used: &mut Vec<String>) -> String {
name
}
/// `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() };
match markup.find('>') {
Some(i) => format!("{} v-if=\"{guard}\"{}", &markup[..i], &markup[i..]),
None => markup.to_string(),
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('\n', "<br>")
}
@@ -777,15 +787,20 @@ impl<'c, 'a> Markup<'c, 'a> {
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") {
Some(p) => Some(format!("{}{{{}{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;
@@ -830,7 +845,10 @@ impl<'c, 'a> Markup<'c, 'a> {
let vue = self.lang == CodeLang::Vue;
let leptos = self.lang == CodeLang::Leptos;
let open = open("div");
let slot_ident = slot.map(|p| class_name(&p.ident, &mut Vec::new()));
let slot_ident = slot.map(|p| match self.ctx.comp {
Some(c) => crate::component_codegen::slot_name(c, p),
None => class_name(&p.ident, &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))
@@ -938,7 +956,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);
@@ -1111,7 +1129,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);
+56 -16
View File
@@ -447,6 +447,12 @@ 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")
}
/// 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 { .. }))?;
@@ -464,7 +470,8 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
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::React | CodeLang::Vue) => js_string(value.as_str().unwrap_or("")),
(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) => {
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
@@ -473,7 +480,10 @@ 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 js {
if l == CodeLang::Vue {
return vue_string(v);
}
if l == CodeLang::React {
return js_string(v);
}
let swift = l == CodeLang::SwiftUi;
@@ -484,6 +494,8 @@ 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) {
// 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(),
@@ -500,6 +512,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, &mut used);
if q.ident == p.ident {
return name;
}
}
class_name(&p.ident, &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)>> {
@@ -524,23 +549,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 matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet. shortcut: inside a layer tree it brings no import
// line; collect them at the top of the tree's code when people ask for it.
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 (content, css) = slot_content(doc, *layer, lang, depth);
slot_css.push_str(&css);
args.push((&p.ident, content, false));
}
continue;
}
@@ -552,7 +586,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.
@@ -579,18 +613,24 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
let slots: String = args
.iter()
.filter(|(name, _, _)| is_slot(name))
.map(|(name, expr, _)| format!("{pad} <template #{}>\n{expr}{pad} </template>\n", class_name(name, &mut Vec::new())))
.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, &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(", ")),
_ => 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).
fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> (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 {
@@ -600,14 +640,14 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
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);
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.
@@ -615,21 +655,21 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
for k in kids {
m.node(k, Some(n), depth + 2, "");
}
m.markup
(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())
}
_ => {
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())
}
}
}
@@ -752,7 +792,7 @@ fn vue(doc: &Document, c: &Component) -> String {
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| js_string(o)).collect::<Vec<_>>().join(" | "),
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(),
@@ -220,12 +220,16 @@ 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("<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");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["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]
+75
View File
@@ -182,3 +182,78 @@ fn a_keyword_property_name_stays_usable() {
let out = vue(&mut s, c);
assert!(out.contains("defaultValue"), "{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"})));
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}));
let out = vue(&mut s, c);
// The call's early return must not swallow the guard `node` put on it.
assert!(out.contains("v-if=\"showCard\""), "{out}");
assert_eq!(out.matches("v-if=\"showCard\"").count(), 1, "{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(".swatch {"), "{out}");
assert!(out.contains("#FF0000"), "{out}");
assert!(code(&mut s, screen, "react").contains(".swatch {"));
}
+1 -1
View File
@@ -228,7 +228,7 @@ status = "draft — needs native review"
"Changes on main" = "প্রধান (main)-এ পরিবর্তন"
"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন"
"Changes unknown" = "পরিবর্তন অজ্ঞাত"
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI বা Compose বেছে নিন।"
"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI বা Compose বেছে নিন।"
"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।"
"Choose collection" = "সংগ্রহ (collection) বেছে নিন"
"Choose image" = "ছবি বেছে নিন"
+1 -1
View File
@@ -223,7 +223,7 @@ status = "draft — needs native review"
"Changes unknown" = "Muutokset tuntemattomia"
"Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…"
"Checking the server…" = "Tarkistetaan palvelinta…"
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI tai Compose yhdistääksesi tämän komponentin sen koodiin."
"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI tai Compose yhdistääksesi tämän komponentin sen koodiin."
"Choose another profile to change it." = "Vaihda valitsemalla toinen profiili."
"Choose collection" = "Valitse kokoelma"
"Choose image" = "Valitse kuva"
+1 -1
View File
@@ -230,7 +230,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 or Compose above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, SwiftUI ან Compose."
"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, SwiftUI ან Compose."
"Choose another profile to change it." = "მის შესაცვლელად აირჩიეთ სხვა პროფილი."
"Choose collection" = "კოლექციის არჩევა"
"Choose image" = "გამოსახულების არჩევა"
+1 -1
View File
@@ -223,7 +223,7 @@ status = "draft — needs native review"
"Changes unknown" = "Onbekende wijzigingen"
"Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…"
"Checking the server…" = "Server controleren…"
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI of Compose om dit component aan zijn code te koppelen."
"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI of Compose om dit component aan zijn code te koppelen."
"Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen."
"Choose collection" = "Kies een verzameling"
"Choose image" = "Afbeelding kiezen"
+1 -1
View File
@@ -226,7 +226,7 @@ status = "draft — needs native review"
"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 or Compose above to connect this component to its code." = "Wybierz React, Leptos, SwiftUI lub Compose powyżej, aby połączyć ten komponent z jego kodem."
"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Wybierz React, Leptos, SwiftUI lub Compose 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"
+1 -1
View File
@@ -55,7 +55,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, React and Vue | 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
View File
@@ -356,7 +356,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, Vue, SwiftUI, Compose): an import path, the name and a template whose `{property}`
language (React, Leptos, Vue, SwiftUI, Compose): 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
+5 -4
View File
@@ -1291,7 +1291,7 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
`: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="Hover" />`, `Button(state:
`<Button state="Hover" />`, `Button(state:
.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
are snake_case; names that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
@@ -1303,9 +1303,10 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
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}`; a Vue binding is quoted
instead, so `:size={size}` gives `:size="Large"` and `:disabled={disabled}` gives
`:disabled="true"` — write the placeholder without quotes and let the value bring them; a
`size="Large"` and `disabled={disabled}` gives `disabled={true}`; a Vue binding takes an
expression, so `:size={size}` gives `:size="&quot;Large&quot;"` 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 `&quot;`; Leptos
imports with `use