diff --git a/crates/oxc_angular_compiler/src/directive/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index 960b31fce..a487a9861 100644 --- a/crates/oxc_angular_compiler/src/directive/compiler.rs +++ b/crates/oxc_angular_compiler/src/directive/compiler.rs @@ -424,6 +424,13 @@ pub enum InputFlags { HasDecoratorInputTransform = 2, // 1 << 1 } +/// Check if an object property key needs quoting because it contains unsafe characters. +/// +/// Matches Angular's `UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/` from `render3/view/util.ts`. +fn needs_object_key_quoting(key: &str) -> bool { + key.contains('.') || key.contains('-') +} + /// Creates the inputs literal map. /// /// Ported from Angular's `conditionallyCreateDirectiveBindingLiteral` in `render3/view/util.ts`. @@ -512,7 +519,8 @@ pub fn create_inputs_literal<'a>( )) }; - entries.push(LiteralMapEntry { key: declared_name.clone(), value, quoted: false }); + let quoted = needs_object_key_quoting(declared_name); + entries.push(LiteralMapEntry { key: declared_name.clone(), value, quoted }); } Some(OutputExpression::LiteralMap(Box::new_in( @@ -533,6 +541,7 @@ pub fn create_outputs_literal<'a>( let mut entries = Vec::new_in(allocator); for (class_name, binding_name) in outputs { + let quoted = needs_object_key_quoting(class_name); entries.push(LiteralMapEntry { key: class_name.clone(), value: OutputExpression::Literal(Box::new_in( @@ -542,7 +551,7 @@ pub fn create_outputs_literal<'a>( }, allocator, )), - quoted: false, + quoted, }); } @@ -1547,4 +1556,97 @@ mod tests { output ); } + + #[test] + fn test_create_inputs_literal_quotes_dotted_key() { + let allocator = Allocator::default(); + let inputs = vec![R3InputMetadata { + class_property_name: Atom::from("fxFlexAlign.xs"), + binding_property_name: Atom::from("fxFlexAlign.xs"), + required: false, + is_signal: false, + transform_function: None, + }]; + let expr = create_inputs_literal(&allocator, &inputs).unwrap(); + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&expr); + assert!( + output.contains(r#""fxFlexAlign.xs""#), + "Dotted key should be quoted. Got:\n{output}" + ); + } + + #[test] + fn test_create_inputs_literal_quotes_hyphenated_key() { + let allocator = Allocator::default(); + let inputs = vec![R3InputMetadata { + class_property_name: Atom::from("fxFlexAlign.lt-sm"), + binding_property_name: Atom::from("fxFlexAlign.lt-sm"), + required: false, + is_signal: false, + transform_function: None, + }]; + let expr = create_inputs_literal(&allocator, &inputs).unwrap(); + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&expr); + assert!( + output.contains(r#""fxFlexAlign.lt-sm""#), + "Hyphenated key should be quoted. Got:\n{output}" + ); + } + + #[test] + fn test_create_inputs_literal_no_quotes_for_simple_identifier() { + let allocator = Allocator::default(); + let inputs = vec![R3InputMetadata { + class_property_name: Atom::from("fxFlexAlign"), + binding_property_name: Atom::from("fxFlexAlign"), + required: false, + is_signal: false, + transform_function: None, + }]; + let expr = create_inputs_literal(&allocator, &inputs).unwrap(); + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&expr); + // Key should be bare (unquoted), followed by colon + assert!( + output.contains("fxFlexAlign:"), + "Simple identifier key should be bare. Got:\n{output}" + ); + // Key should NOT be quoted — check that no quoted form appears before the colon + assert!( + !output.contains(r#""fxFlexAlign":"#), + "Simple identifier key should not be quoted. Got:\n{output}" + ); + } + + #[test] + fn test_create_outputs_literal_quotes_dotted_key() { + let allocator = Allocator::default(); + let outputs = vec![(Atom::from("activate.xs"), Atom::from("activateXs"))]; + let expr = create_outputs_literal(&allocator, &outputs).unwrap(); + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&expr); + assert!( + output.contains(r#""activate.xs""#), + "Dotted output key should be quoted. Got:\n{output}" + ); + } + + #[test] + fn test_create_outputs_literal_no_quotes_for_simple_identifier() { + let allocator = Allocator::default(); + let outputs = vec![(Atom::from("activate"), Atom::from("activate"))]; + let expr = create_outputs_literal(&allocator, &outputs).unwrap(); + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&expr); + assert!( + output.contains("activate:"), + "Simple identifier output key should be bare. Got:\n{output}" + ); + assert!( + !output.contains(r#""activate":"#), + "Simple identifier output key should not be quoted. Got:\n{output}" + ); + } } diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 7e2865739..417944c7b 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -43,6 +43,18 @@ use oxc_span::{GetSpan, SourceType}; use crate::optimizer::Edit; use crate::pipeline::selector::{R3SelectorElement, parse_selector_to_r3_selector}; +/// Check if an object property key needs quoting because it contains unsafe characters. +/// +/// Matches Angular's `UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/` from `render3/view/util.ts`. +fn needs_object_key_quoting(key: &str) -> bool { + key.contains('.') || key.contains('-') +} + +/// Quote a property key if it contains unsafe characters (dots or hyphens). +fn quote_key(key: &str) -> String { + if needs_object_key_quoting(key) { format!("\"{key}\"") } else { key.to_string() } +} + /// Partial declaration function names to link. const DECLARE_FACTORY: &str = "\u{0275}\u{0275}ngDeclareFactory"; const DECLARE_INJECTABLE: &str = "\u{0275}\u{0275}ngDeclareInjectable"; @@ -1234,10 +1246,12 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source } }; + let quoted_key = quote_key(&key); + match &p.value { // Simple string: propertyName: "publicName" → keep as is Expression::StringLiteral(lit) => { - entries.push(format!("{key}: \"{}\"", lit.value)); + entries.push(format!("{quoted_key}: \"{}\"", lit.value)); } // Array: check if it's declaration format [publicName, classPropertyName] // and convert to definition format [InputFlags, publicName, classPropertyName] @@ -1253,17 +1267,17 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source // Convert to: [0, "publicName", "classPropertyName"] let arr_source = &source[arr.span.start as usize + 1..arr.span.end as usize - 1]; - entries.push(format!("{key}: [0, {arr_source}]")); + entries.push(format!("{quoted_key}: [0, {arr_source}]")); } else { // Already in definition format or unknown, keep as is let val = &source[p.value.span().start as usize..p.value.span().end as usize]; - entries.push(format!("{key}: {val}")); + entries.push(format!("{quoted_key}: {val}")); } } else { // 3+ elements likely already in definition format, keep as is let val = &source[p.value.span().start as usize..p.value.span().end as usize]; - entries.push(format!("{key}: {val}")); + entries.push(format!("{quoted_key}: {val}")); } } // Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc. @@ -1290,20 +1304,21 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source if flags == 0 && transform.is_none() && public_name == declared_name { // Simple case: no flags, no transform, names match - entries.push(format!("{key}: \"{public_name}\"")); + entries.push(format!("{quoted_key}: \"{public_name}\"")); } else if let Some(transform_fn) = transform { entries.push(format!( - "{key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]" + "{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]" )); } else { - entries - .push(format!("{key}: [{flags}, \"{public_name}\", \"{declared_name}\"]")); + entries.push(format!( + "{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\"]" + )); } } // Unknown format, keep as is _ => { let val = &source[p.value.span().start as usize..p.value.span().end as usize]; - entries.push(format!("{key}: {val}")); + entries.push(format!("{quoted_key}: {val}")); } } } diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs new file mode 100644 index 000000000..2bfa9ac09 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -0,0 +1,88 @@ +//! Tests for Angular linker input/output key quoting. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::linker::link; + +/// Helper to build a ɵɵngDeclareDirective source with a given inputs block. +fn make_directive_source(inputs_block: &str) -> String { + format!( + r#"import * as i0 from "@angular/core"; +export class MyDir {{}} +MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", inputs: {{ {inputs_block} }} }});"# + ) +} + +/// Helper to build a ɵɵngDeclareDirective source with a given outputs block. +fn make_directive_source_with_outputs(outputs_block: &str) -> String { + format!( + r#"import * as i0 from "@angular/core"; +export class MyDir {{}} +MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", outputs: {{ {outputs_block} }} }});"# + ) +} + +#[test] +fn test_link_inputs_dotted_key() { + let allocator = Allocator::default(); + let code = make_directive_source(r#""fxFlexAlign.xs": "fxFlexAlignXs""#); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_inputs_hyphenated_key() { + let allocator = Allocator::default(); + let code = make_directive_source(r#""fxFlexAlign.lt-sm": "fxFlexAlignLtSm""#); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_inputs_simple_identifier() { + let allocator = Allocator::default(); + let code = make_directive_source(r#"fxFlexAlign: "fxFlexAlign""#); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_inputs_object_format_dotted_key() { + let allocator = Allocator::default(); + let code = make_directive_source( + r#""fxFlexAlign.xs": { classPropertyName: "fxFlexAlignXs", publicName: "fxFlexAlign.xs", isRequired: false, isSignal: false }"#, + ); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_inputs_array_format_dotted_key() { + let allocator = Allocator::default(); + let code = make_directive_source(r#""fxFlexAlign.xs": ["fxFlexAlign.xs", "fxFlexAlignXs"]"#); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_outputs_dotted_key() { + let allocator = Allocator::default(); + let code = make_directive_source_with_outputs(r#""activate.xs": "activateXs""#); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_outputs_hyphenated_key() { + let allocator = Allocator::default(); + let code = make_directive_source_with_outputs(r#""activate.lt-sm": "activateLtSm""#); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_outputs_simple_identifier() { + let allocator = Allocator::default(); + let code = make_directive_source_with_outputs(r#"activate: "activate""#); + let result = link(&allocator, &code, "test.mjs"); + insta::assert_snapshot!(result.code); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_array_format_dotted_key.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_array_format_dotted_key.snap new file mode 100644 index 000000000..1faf061f5 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_array_format_dotted_key.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 54 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": [0, "fxFlexAlign.xs", "fxFlexAlignXs"] }, standalone: false }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_dotted_key.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_dotted_key.snap new file mode 100644 index 000000000..ecca7ee45 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_dotted_key.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 20 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": "fxFlexAlignXs" }, standalone: false }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_hyphenated_key.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_hyphenated_key.snap new file mode 100644 index 000000000..11ae157d7 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_hyphenated_key.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 28 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.lt-sm": "fxFlexAlignLtSm" }, standalone: false }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_object_format_dotted_key.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_object_format_dotted_key.snap new file mode 100644 index 000000000..74a3b2506 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_object_format_dotted_key.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 46 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": [0, "fxFlexAlign.xs", "fxFlexAlignXs"] }, standalone: false }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_simple_identifier.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_simple_identifier.snap new file mode 100644 index 000000000..80a67c13e --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_inputs_simple_identifier.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 36 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { fxFlexAlign: "fxFlexAlign" }, standalone: false }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_dotted_key.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_dotted_key.snap new file mode 100644 index 000000000..3ae23f85e --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_dotted_key.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 71 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { "activate.xs": "activateXs" }, standalone: false }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_hyphenated_key.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_hyphenated_key.snap new file mode 100644 index 000000000..bb1959b31 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_hyphenated_key.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 79 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { "activate.lt-sm": "activateLtSm" }, standalone: false }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_simple_identifier.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_simple_identifier.snap new file mode 100644 index 000000000..2912e8be6 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_outputs_simple_identifier.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +assertion_line: 87 +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyDir {} +MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { activate: "activate" }, standalone: false });