From 878e2997242a1443e081014e17a1213e4c3fe0dd Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:29:46 +0800 Subject: [PATCH] M6: keep Vue attribute context and escape plain attribute entities A quoted Vue attribute stays open across placeholders, so `title="{a} {b}"` escapes both values as attribute text. Plain Vue attribute values in instance calls now HTML-escape `&`, `<` and `>`, so a literal `&` survives the round trip. Tests cover both. --- crates/engine/src/code_connect.rs | 57 ++++++++++++-------- crates/engine/src/code_connect_tests.rs | 5 ++ crates/engine/src/component_codegen.rs | 17 +++++- crates/engine/src/component_codegen_tests.rs | 6 +++ 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index eeaef08a..1ba7cc79 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -246,9 +246,19 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], let checkable = link.template.len() <= MAX_TEMPLATE * 4; let pieces = checkable.then(|| parse(&link.template).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(&link.template)]); let mut out = String::new(); + // A quoted attribute stays open across substitutions: `title="{a} {b}"` must treat both + // placeholders as attribute values, not text after the first one. + let mut vue_attr: Option<(bool, Option)> = None; for piece in pieces { 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); @@ -257,29 +267,31 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], // 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_attribute(&out) { - Some((true, quote)) => { - let expr = html_attr_expr(&e, quote); - if quote.is_some() { - out.push_str(&expr); + 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 { - out.push('"'); - out.push_str(&expr); - out.push('"'); - } - } - Some((false, quote)) => { - 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('"'); + 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 { @@ -287,6 +299,7 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], _ => e.clone(), }; out.push_str(&text); + vue_attr = None; } } } else { diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 33f442d9..721908b3 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -232,6 +232,11 @@ fn vue_links_are_a_default_import_with_quoted_bindings() { assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n\n"); ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "{title}"})); assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\nBuy\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": ""})); + assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n\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"); diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index e1b77ff6..2f164238 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -453,6 +453,17 @@ 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 { let n = doc.node(comp).filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))?; @@ -609,7 +620,11 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept let attrs: String = args .iter() .filter(|(name, _, _)| !is_slot(name)) - .map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" :{name}=\"{}\"", expr.replace('"', """)) }) + .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() diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 7d710ef7..f8f14254 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -129,6 +129,12 @@ fn instances_are_calls_passing_the_values_they_set() { assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n"); let vue = code(&mut s, i, "vue"); assert!(vue.starts_with("\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"], "\n"); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}})); + assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n"); } #[test]