diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs index 9f45c50cd..f8aa2b3b8 100644 --- a/crates/aep/src/export/plan.rs +++ b/crates/aep/src/export/plan.rs @@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col); let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col)); let mut name = cut_name(&layer.name, 255, &what("layer"), col); - // A text layer named after its text stores no name (110, 113: After Effects shows the text). + // A text layer named after its text stores no name (110, 113: After Effects shows the text): + // the layer's own rule decides (#3233). if let LayerSrc::Text(tp) = &src - && tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::() == name) + && layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text)) { name.clear(); } diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..fa66ee411 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -479,7 +479,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition .map(|d| crate::import::text::first_paragraph(&d.raw_text)) .filter(|t| !t.trim().is_empty()) }; - let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string()); + let name = aep_name + .clone() + .or_else(|| src.as_ref().map(|s| s.name.clone())) + .or_else(text_name) + // An empty text layer without a name is named as a new one is (#3233). + .unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string()); let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap, category: LossCategory, what: &str| { cx.col.lose(category, format!("{comp_name}: {what} '{name}'")); skipped.insert(h.id, what.to_string()); @@ -537,6 +542,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition }; let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None); l.name = name.clone(); + // A text layer without a stored name follows its text; a stored name is the user's (#3233). + if is_text { + l.name_follows_text = Some(aep_name.is_none()); + } l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None); apply_timing(&mut l, &h, &tm, &name, &mut cx.col); apply_switches(&mut l, &h); diff --git a/crates/aep/tests/it/export/text.rs b/crates/aep/tests/it/export/text.rs index 32c7572c5..32e55a269 100644 --- a/crates/aep/tests/it/export/text.rs +++ b/crates/aep/tests/it/export/text.rs @@ -196,6 +196,48 @@ fn documents_built_in_code_are_written_as_their_fields() { assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text)); } +/// #3233: whether a text layer's name follows its text survives export and import. A followed +/// name is written as no name (After Effects shows the text) and comes back following; a name the +/// user gave is written, even when it equals the text, and comes back as the user's. Without a +/// recorded state (older projects) a name equal to the text follows. +#[test] +fn a_text_layer_name_follows_its_text_through_export_and_import() { + for (name, follows, stored) in [ + ("Hello", Some(false), Some("Hello")), + ("Hello 2", Some(true), None), + ("Hello", None, None), + ("Text 2", None, None), + ("Text Title", None, Some("Text Title")), + ("Text Title", Some(false), Some("Text Title")), + ] { + let case = format!("{name:?} {follows:?}"); + let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]); + with_layer(&mut p, c, 0, |l| { + l.name = name.into(); + l.name_follows_text = follows; + }); + let r = export_all(&p); + let file = parse(&r.bytes).expect("parse"); + let view = effectcraft_aep::Project::new(&file).expect("project"); + let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer"); + assert_eq!(layer.name().as_deref(), stored, "stored name ({case})"); + let back = reimport(&r.bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})"); + assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})"); + } + // An empty text layer that follows its text is written without a name, as After Effects + // writes it, and comes back named "Text", still following. + let (mut p, c) = text_project(vec![TextDoc::plain("")]); + with_layer(&mut p, c, 0, |l| { + l.name = "Text".into(); + l.name_follows_text = Some(true); + }); + let back = reimport(&export_all(&p).bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer"); +} + #[test] fn what_a_text_document_cannot_hold_is_reported() { let mut d = TextDoc::plain("x"); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f88ab32c8..b015b72b4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -114,7 +114,15 @@ pub fn specs() -> Vec { has_layers, set_switch ), - cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename), + cmd!( + "layer.rename", + "Rename", + [], + Some("Enter"), + "{layer?, name} — a text layer's name follows its text until renamed; an empty name follows it again", + has_layers, + rename + ), cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment), cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend), cmd!( diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index 5eb253beb..3015f4ecf 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result { KV::Str(t) => TextDoc::plain(&t), _ => TextDoc::default(), }; + let before = effectcraft_project::text_layer_name(&doc.text); apply_text_params("layer.setText", p, &mut doc, range.clone())?; pr.set_value_at(lt, KV::Text(Box::new(doc.clone()))); - if let Some(first) = doc.text.lines().next() - && str_p(p, "text").is_some() - && !first.is_empty() - && (l.name.starts_with("Text") || l.name.is_empty()) - { - l.name = first.chars().take(40).collect(); + // Text layers are named after their text until renamed (#3233). + let after = effectcraft_project::text_layer_name(&doc.text); + if str_p(p, "text").is_some() && !after.is_empty() && l.name_follows_text(&before) { + l.name = after; + l.name_follows_text = Some(true); } Ok(()) })?; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 621ae866b..0196b5aee 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } let edit = b_p(p, "edit").unwrap_or(false); let id = s.edit("New Text Layer", None, |proj, st| { - let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect()); + let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text)); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + // Named after its text, the name follows the text until renamed; a given name does not. + l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty)); if let Some(pr) = l.props.prop_mut("text/sourceText") { pr.value = KV::Text(Box::new(doc.clone())); } diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 366c1eeb1..afee522c6 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) { pub(super) fn rename(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.rename")?; let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); + let t = s.time(); s.edit("Rename Layer", None, |proj, _| { - layer_mut(proj, cid, lid)?.name = name.clone(); + set_layer_name(layer_mut(proj, cid, lid)?, &name, t); Ok(()) })?; Ok(Value::Null) } +/// Give layer `l` the name `name` the user typed (comp time `t` picks the text). A text layer's +/// name follows its text until the user renames it; an empty name makes it follow the text again +/// (After Effects, #3233). Committing the unchanged name changes nothing. +pub(super) fn set_layer_name(l: &mut Layer, name: &str, t: effectcraft_time::Tick) { + let text_name = + l.props.prop("text/sourceText").filter(|_| matches!(l.source, effectcraft_project::LayerSource::Text)).map(|pr| match pr.value_at(l.layer_time(t)) { + KV::Text(d) => effectcraft_project::text_layer_name(&d.text), + KV::Str(txt) => effectcraft_project::text_layer_name(&txt), + _ => String::new(), + }); + match text_name { + Some(first) if name.trim().is_empty() => { + l.name = if first.is_empty() { "Text".to_string() } else { first }; + l.name_follows_text = Some(true); + } + Some(_) if name != l.name => { + l.name = name.to_string(); + l.name_follows_text = Some(false); + } + _ => l.name = name.to_string(), + } +} + /// The Timeline's Comment column. pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index eb0f610f9..87403fb26 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -172,10 +172,12 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let pixel_aspect = pixel_aspect_p(p)?; let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); + let t = s.time(); let solid = s.edit("Layer Settings", None, |proj, _| { let src = layer_mut(proj, cid, lid)?.source.clone(); if let Some(n) = &name { - layer_mut(proj, cid, lid)?.name = n.clone(); + // A user name: a text layer's name stops following its text (#3233). + super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t); } let LayerSource::Solid { item } = src else { return Ok(None) }; let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index cdb650486..9240f0c1a 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -6,7 +6,7 @@ //! Positions are character indices. The selection is `anchor..caret` (either order). use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV}; -use effectcraft_project::{ItemId, LayerId, LayerSource}; +use effectcraft_project::{ItemId, LayerId, LayerSource, text_layer_name}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -288,10 +288,6 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit { } } -fn first_line_name(text: &str) -> String { - text.lines().next().unwrap_or("").chars().take(40).collect() -} - /// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the /// session's selection. pub(crate) fn edit_doc( @@ -319,12 +315,13 @@ pub(crate) fn edit_doc( // already has) must not turn a static Str into Text or add a keyframe at the CTI: that // would change the project and record an empty undo step. if doc != original { - let before = first_line_name(&original.text); - let after = first_line_name(&doc.text); + let before = text_layer_name(&original.text); + let after = text_layer_name(&doc.text); pr.set_value_at(lt, KV::Text(Box::new(doc))); - // Text layers are named after their text until renamed. - if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { + // Text layers are named after their text until renamed (#3233). + if before != after && !after.is_empty() && l.name_follows_text(&before) { l.name = after; + l.name_follows_text = Some(true); } } Ok(()) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index eb1de6f89..bcb4f0559 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1417,3 +1417,102 @@ fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() { assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied"); assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected"); } + +fn layer_name(s: &Session, t: u64) -> String { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().name.clone() +} + +/// Replace the text with `text` in the on-canvas editor (everything selected, typed, committed). +fn retype(s: &mut Session, t: u64, text: &str) { + begin_edit(s, t); + s.execute("text.insert", json!({"text": text})).unwrap(); + s.execute("text.endEdit", json!({})).unwrap(); +} + +/// #3233: a text layer's name follows its Source Text, edited on canvas or by `layer.setText`, +/// until the user renames it (After Effects). A user name stays, even one that starts with +/// "Text", and so does a copy's; renaming to an empty name follows the text again. +#[test] +fn a_text_layer_name_follows_its_text_until_renamed() { + let (mut s, t) = session("Hello"); + assert_eq!(layer_name(&s, t), "Hello"); + retype(&mut s, t, "World"); + assert_eq!(layer_name(&s, t), "World", "a new text layer follows its text"); + s.execute("layer.setText", json!({"layer": t, "text": "Again"})).unwrap(); + assert_eq!(layer_name(&s, t), "Again"); + s.execute("layer.rename", json!({"layer": t, "name": "Text Title"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Changed"})).unwrap(); + assert_eq!(layer_name(&s, t), "Text Title", "layer.setText keeps the user's name"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Text Title", "typing keeps the user's name"); + s.execute("layer.select", json!({"layers": [t]})).unwrap(); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + let copy_name = layer_name(&s, copy); + s.execute("layer.setText", json!({"layer": copy, "text": "Copied"})).unwrap(); + assert_eq!(layer_name(&s, copy), copy_name, "a copy keeps the user's name too"); + // Renaming to an empty name: the name is the text's again, and follows it. + s.execute("layer.rename", json!({"layer": t, "name": ""})).unwrap(); + assert_eq!(layer_name(&s, t), "Typed", "an empty name shows the text"); + retype(&mut s, t, "Followed"); + assert_eq!(layer_name(&s, t), "Followed", "and follows it again"); + // Committing the unchanged name (Enter, Enter) does not stop the following. + s.execute("layer.rename", json!({"layer": t, "name": "Followed"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Still"})).unwrap(); + assert_eq!(layer_name(&s, t), "Still"); + // A name given in Layer Settings is the user's too. The UI offers Layer Settings only for + // solids, nulls, cameras and lights (as After Effects does); this guards the command, which + // agents can call on any layer. + s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap(); + assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good"); + // A layer given a name when it is made does not follow. + let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap(); + assert_eq!(layer_name(&s, named), "Caption"); +} + +/// #3233: After Effects breaks paragraphs with a bare carriage return; the name is the first +/// paragraph, without the break, and an older layer named after it counts as following. +#[test] +fn a_text_layer_name_ends_at_a_bare_carriage_return() { + let (mut s, t) = session("A\rB"); + assert_eq!(layer_name(&s, t), "A"); + s.execute("layer.setText", json!({"layer": t, "text": "C\rD"})).unwrap(); + assert_eq!(layer_name(&s, t), "C"); + retype(&mut s, t, "E\rF"); + assert_eq!(layer_name(&s, t), "E"); + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = "Para1".into(); + l.name_follows_text = None; + l.props.prop_mut("text/sourceText").unwrap().value = effectcraft_keyframe::Value::Text(Box::new(TextDoc::plain("Para1\rPara2"))); + s.execute("layer.setText", json!({"layer": t, "text": "Next\rLine"})).unwrap(); + assert_eq!(layer_name(&s, t), "Next", "an older layer named after its first paragraph follows"); +} + +/// #3233: projects saved before the state was recorded load without it; there a name that is +/// the text's, empty, or the default "Text"/"Text 2"… follows the text, any other name stays. +#[test] +fn older_text_layers_without_the_name_state_follow_as_before() { + let (mut s, t) = session("Hello"); + let mut v = serde_json::to_value(s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap()).unwrap(); + v.as_object_mut().unwrap().remove("name_follows_text"); + let old: effectcraft_project::Layer = serde_json::from_value(v).unwrap(); + assert_eq!(old.name_follows_text, None, "an older project has no state"); + for (name, follows) in [("Hello", true), ("", true), ("Text", true), ("Text 3", true), ("Text Title", false), ("Text 3b", false), ("Title", false)] { + let l = effectcraft_project::Layer { name: name.into(), ..old.clone() }; + assert_eq!(l.name_follows_text("Hello"), follows, "{name:?}"); + } + let legacy = |s: &mut Session, name: &str| { + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = name.into(); + l.name_follows_text = None; + }; + legacy(&mut s, "Hello"); + s.execute("layer.setText", json!({"layer": t, "text": "World"})).unwrap(); + assert_eq!(layer_name(&s, t), "World", "a name equal to the text follows it"); + legacy(&mut s, "Title"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Title", "another name stays"); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 1aa8d7c90..7a960dbfb 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -935,6 +935,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource Layer { id, name: comp.unique_layer_name(name), + name_follows_text: None, source, label, comment: String::new(), diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..f7b3b5e51 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -299,6 +299,8 @@ impl Layer { let Layer { id, name, + // Whether the name follows the text changes no pixels until the name changes. + name_follows_text: _, source, label, comment, @@ -474,6 +476,11 @@ pub enum AutoOrient { pub struct Layer { pub id: LayerId, pub name: String, + /// A text layer's name follows the first line of its Source Text until the user renames it + /// (After Effects). `Some(true)`: it follows, `Some(false)`: the user named it. `None` (older + /// projects, layers made before this was recorded): see [`Layer::name_follows_text()`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_follows_text: Option, pub source: LayerSource, pub label: Label, #[serde(default)] @@ -512,7 +519,26 @@ pub struct Layer { pub props: PropGroup, } +/// The name a text layer takes from its text: the first 40 characters of the first line. A line +/// ends at a line feed or at a bare carriage return (After Effects' paragraph break). +pub fn text_layer_name(text: &str) -> String { + text.split(['\r', '\n']).next().unwrap_or("").chars().take(40).collect() +} + +/// A text layer's default name when its text gives none: "Text", numbered "Text 2", "Text 3"… +fn is_default_text_name(name: &str) -> bool { + name == "Text" || name.strip_prefix("Text ").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) +} + impl Layer { + /// Whether this text layer's name follows its Source Text, whose first line gives the name + /// `current` (see [`text_layer_name`]). Without a recorded state (an older project), a name + /// that is empty, the text's own, or the default "Text"/"Text 2"… follows; any other name was + /// given by the user. + pub fn name_follows_text(&self, current: &str) -> bool { + self.name_follows_text.unwrap_or_else(|| self.name.is_empty() || self.name == current || is_default_text_name(&self.name)) + } + /// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map, /// Audio Spectrum's Audio Layer…) and tracker targets. pub fn referenced_layers(&self) -> Vec { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 079b81765..fabcd607f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -43,7 +43,7 @@ pub use item::NO_LAYER; pub use item::{Heard, Item, ItemId, ItemKind, LayerId}; pub use layer::{ AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect, - reads_other_frames, + reads_other_frames, text_layer_name, }; pub use layers::Layers; pub use settings::{ diff --git a/docs/agents.md b/docs/agents.md index ac77439b7..27664cb73 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -713,6 +713,8 @@ in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md). step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. + A text layer's name follows its text until it is renamed; renaming it to an empty name makes + it follow the text again (After Effects). * Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its