-
Notifications
You must be signed in to change notification settings - Fork 616
feat(microsoft-fast-build): map data-* attributes to nested dataset state #7398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
97429cd
171e42c
c3e9f8a
84c8ca8
36e3530
d6b7a6e
4a9ca98
cd43b49
b009093
8ab357a
40d8af9
53f004b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "type": "minor", | ||
| "comment": "feat: convert dataset.X attribute names to data-X in SSR renderer", | ||
| "packageName": "@microsoft/fast-build", | ||
| "email": "7559015+janechu@users.noreply.github.com", | ||
| "dependentChangeType": "patch" | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -221,6 +221,35 @@ pub fn count_tag_attribute_bindings(tag: &str) -> (usize, usize) { | |||||||||||||||||||||||||
| (db, sb) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // ββ Data attribute helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// Convert a kebab-case string to camelCase. | ||||||||||||||||||||||||||
| /// "date-of-birth" β "dateOfBirth", "name" β "name" | ||||||||||||||||||||||||||
| fn kebab_to_camel(s: &str) -> String { | ||||||||||||||||||||||||||
| let mut result = String::with_capacity(s.len()); | ||||||||||||||||||||||||||
| let mut next_upper = false; | ||||||||||||||||||||||||||
| for ch in s.chars() { | ||||||||||||||||||||||||||
| if ch == '-' { | ||||||||||||||||||||||||||
| next_upper = true; | ||||||||||||||||||||||||||
| } else if next_upper { | ||||||||||||||||||||||||||
| result.push(ch.to_ascii_uppercase()); | ||||||||||||||||||||||||||
| next_upper = false; | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| result.push(ch); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| result | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// Convert a `data-kebab-case` HTML attribute name to its full dot-notation | ||||||||||||||||||||||||||
| /// dataset path, following the MDN HTMLElement.dataset naming convention. | ||||||||||||||||||||||||||
| /// Returns `None` for names that do not start with `data-`. | ||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||
| /// Examples: `"data-date-of-birth"` β `"dataset.dateOfBirth"`, `"data-name"` β `"dataset.name"` | ||||||||||||||||||||||||||
| pub fn data_attr_to_dataset_key(name: &str) -> Option<String> { | ||||||||||||||||||||||||||
| name.strip_prefix("data-").map(|rest| format!("dataset.{}", kebab_to_camel(rest))) | ||||||||||||||||||||||||||
|
Comment on lines
+246
to
+250
|
||||||||||||||||||||||||||
| /// Returns `None` for names that do not start with `data-`. | |
| /// | |
| /// Examples: `"data-date-of-birth"` β `"dataset.dateOfBirth"`, `"data-name"` β `"dataset.name"` | |
| pub fn data_attr_to_dataset_key(name: &str) -> Option<String> { | |
| name.strip_prefix("data-").map(|rest| format!("dataset.{}", kebab_to_camel(rest))) | |
| /// Returns `None` for names that do not start with `data-` or have an empty suffix. | |
| /// | |
| /// Examples: `"data-date-of-birth"` β `"dataset.dateOfBirth"`, `"data-name"` β `"dataset.name"` | |
| pub fn data_attr_to_dataset_key(name: &str) -> Option<String> { | |
| name.strip_prefix("data-") | |
| .filter(|rest| !rest.is_empty()) | |
| .map(|rest| format!("dataset.{}", kebab_to_camel(rest))) |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ use crate::attribute::{ | |||||||||||
| find_single_brace, skip_single_brace_expr, find_tag_end, read_tag_name, | ||||||||||||
| parse_element_attributes, find_custom_element, | ||||||||||||
| count_tag_attribute_bindings, resolve_attribute_bindings_in_tag, | ||||||||||||
| data_attr_to_dataset_key, | ||||||||||||
| }; | ||||||||||||
| use crate::error::{RenderError, template_context}; | ||||||||||||
| use crate::node::render_node; | ||||||||||||
|
|
@@ -249,11 +250,25 @@ pub fn render_custom_element( | |||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| // Parse attributes and build child state. | ||||||||||||
| // `data-*` attributes are stored using the full dot-notation path returned by | ||||||||||||
| // `data_attr_to_dataset_key` (e.g. `"dataset.dateOfBirth"`), split on the first | ||||||||||||
| // `.` to build a nested state object so `{{dataset.X}}` bindings resolve correctly. | ||||||||||||
| let attrs = parse_element_attributes(open_tag_content); | ||||||||||||
| let mut state_map = std::collections::HashMap::new(); | ||||||||||||
| for (attr_name, value) in &attrs { | ||||||||||||
| let json_val = attribute_to_json_value(value.as_ref(), root, loop_vars); | ||||||||||||
| state_map.insert(attr_name.clone(), json_val); | ||||||||||||
| if let Some(path) = data_attr_to_dataset_key(attr_name) { | ||||||||||||
| if let Some((group, prop)) = path.split_once('.') { | ||||||||||||
| let group_val = state_map | ||||||||||||
| .entry(group.to_string()) | ||||||||||||
| .or_insert_with(|| JsonValue::Object(std::collections::HashMap::new())); | ||||||||||||
|
||||||||||||
| .or_insert_with(|| JsonValue::Object(std::collections::HashMap::new())); | |
| .or_insert_with(|| JsonValue::Object(std::collections::HashMap::new())); | |
| if !matches!(group_val, JsonValue::Object(_)) { | |
| *group_val = JsonValue::Object(std::collections::HashMap::new()); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| mod common; | ||
| use common::{ok, make_locator}; | ||
| use microsoft_fast_build::{render_with_locator, JsonValue}; | ||
| use std::collections::HashMap; | ||
|
|
||
| fn state(entries: Vec<(&str, JsonValue)>) -> JsonValue { | ||
| JsonValue::Object(entries.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) | ||
| } | ||
|
|
||
| fn str_val(s: &str) -> JsonValue { JsonValue::String(s.to_string()) } | ||
| fn bool_val(b: bool) -> JsonValue { JsonValue::Bool(b) } | ||
|
|
||
| fn empty() -> JsonValue { JsonValue::Object(HashMap::new()) } | ||
|
|
||
| // ββ Content bindings β dataset.X reads state["dataset"]["X"] βββββββββββββββββ | ||
|
|
||
| /// `{{dataset.name}}` resolves from nested state `{"dataset": {"name": "Alice"}}`. | ||
| #[test] | ||
| fn test_dataset_content_binding() { | ||
| assert_eq!( | ||
| ok(r#"<p>{{dataset.name}}</p>"#, r#"{"dataset": {"name": "Alice"}}"#), | ||
| r#"<p>Alice</p>"#, | ||
| ); | ||
| } | ||
|
|
||
| /// Multi-level camelCase: `{{dataset.dateOfBirth}}` reads `state.dataset.dateOfBirth`. | ||
| #[test] | ||
| fn test_dataset_content_binding_multi_word() { | ||
| assert_eq!( | ||
| ok( | ||
| r#"{{dataset.dateOfBirth}}"#, | ||
| r#"{"dataset": {"dateOfBirth": "1990-01-01"}}"#, | ||
| ), | ||
| "1990-01-01", | ||
| ); | ||
| } | ||
|
|
||
| // ββ Custom element: data-* attributes populate the dataset state key ββββββββββ | ||
|
|
||
| /// `data-date-of-birth` on a custom element becomes `state.dataset.dateOfBirth` | ||
| /// in the shadow template. | ||
| #[test] | ||
| fn test_data_attr_maps_to_dataset_state() { | ||
| let locator = make_locator(&[( | ||
| "test-el", | ||
| r#"<div data-date-of-birth="{{dataset.dateOfBirth}}"></div>"#, | ||
| )]); | ||
| let result = render_with_locator( | ||
| r#"<test-el data-date-of-birth="1990-01-01"></test-el>"#, | ||
| &empty(), | ||
| &locator, | ||
| ).unwrap(); | ||
| assert!(result.contains(r#"data-date-of-birth="1990-01-01""#), "resolved: {result}"); | ||
| } | ||
|
|
||
| /// `data-date-of-birth="{{dob}}"` β value from parent binding β reaches the | ||
| /// shadow template as `{{dataset.dateOfBirth}}`. | ||
| #[test] | ||
| fn test_data_attr_from_parent_binding() { | ||
| let locator = make_locator(&[( | ||
| "test-el", | ||
| r#"<span>{{dataset.dateOfBirth}}</span>"#, | ||
| )]); | ||
| let root = state(vec![("dob", str_val("1990-01-01"))]); | ||
| let result = render_with_locator( | ||
| r#"<test-el data-date-of-birth="{{dob}}"></test-el>"#, | ||
| &root, | ||
| &locator, | ||
| ).unwrap(); | ||
| assert!(result.contains("1990-01-01"), "content binding: {result}"); | ||
| } | ||
|
|
||
| /// Multiple `data-*` attributes are all grouped under `dataset`. | ||
| #[test] | ||
| fn test_multiple_data_attrs_in_dataset() { | ||
| let locator = make_locator(&[( | ||
| "test-el", | ||
| r#"<p>{{dataset.firstName}} {{dataset.lastName}}</p>"#, | ||
| )]); | ||
| let result = render_with_locator( | ||
| r#"<test-el data-first-name="Ada" data-last-name="Lovelace"></test-el>"#, | ||
| &empty(), | ||
| &locator, | ||
| ).unwrap(); | ||
| assert!(result.contains("Ada"), "firstName: {result}"); | ||
| assert!(result.contains("Lovelace"), "lastName: {result}"); | ||
| } | ||
|
|
||
| /// Non-`data-*` attributes are still accessible as top-level state keys. | ||
| #[test] | ||
| fn test_non_data_attrs_remain_top_level() { | ||
| let locator = make_locator(&[( | ||
| "test-el", | ||
| r#"<div class="{{cls}}" data-id="{{dataset.id}}"></div>"#, | ||
| )]); | ||
| let result = render_with_locator( | ||
| r#"<test-el cls="card" data-id="42"></test-el>"#, | ||
| &empty(), | ||
| &locator, | ||
| ).unwrap(); | ||
| assert!(result.contains(r#"class="card""#), "cls: {result}"); | ||
| assert!(result.contains(r#"data-id="42""#), "dataset.id: {result}"); | ||
| } | ||
|
|
||
| // ββ dataset.X in f-when βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
|
|
||
| /// `<f-when value="{{dataset.active}}">` evaluates `state.dataset.active`. | ||
| #[test] | ||
| fn test_dataset_in_f_when_true() { | ||
| assert_eq!( | ||
| ok( | ||
| r#"<f-when value="{{dataset.active}}">yes</f-when>"#, | ||
| r#"{"dataset": {"active": true}}"#, | ||
| ), | ||
| "yes", | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_dataset_in_f_when_false() { | ||
| assert_eq!( | ||
| ok( | ||
| r#"<f-when value="{{dataset.active}}">yes</f-when>"#, | ||
| r#"{"dataset": {"active": false}}"#, | ||
| ), | ||
| "", | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The change file description looks inverted relative to the PR behavior: it says βconvert dataset.X attribute names to data-Xβ, but the PR maps
data-*attributes into nesteddataset.*state for SSR. Update thecommentstring so release notes accurately describe the actual change.