mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
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.
This commit is contained in:
@@ -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<char>)> = 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 {
|
||||
|
||||
@@ -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<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");
|
||||
// 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");
|
||||
|
||||
@@ -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<String> {
|
||||
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()
|
||||
|
||||
@@ -129,6 +129,12 @@ fn instances_are_calls_passing_the_values_they_set() {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user