Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/oxc_angular_compiler/src/component/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ pub use metadata::{
pub use namespace_registry::NamespaceRegistry;
pub use transform::{
CompiledComponent, HmrTemplateCompileOutput, HostMetadataInput, ImportInfo, ImportMap,
LinkerTemplateOutput, ResolvedResources, TemplateCompileOutput, TransformOptions,
TransformResult, build_import_map, compile_component_template, compile_for_hmr,
compile_host_bindings_for_linker, compile_template_for_hmr, compile_template_for_linker,
compile_template_to_js, compile_template_to_js_with_options, transform_angular_file,
LinkerHostBindingOutput, LinkerTemplateOutput, ResolvedResources, TemplateCompileOutput,
TransformOptions, TransformResult, build_import_map, compile_component_template,
compile_for_hmr, compile_host_bindings_for_linker, compile_template_for_hmr,
compile_template_for_linker, compile_template_to_js, compile_template_to_js_with_options,
transform_angular_file,
};
49 changes: 43 additions & 6 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,10 +1569,20 @@ fn compile_component_full<'a>(
compile_component_host_bindings(allocator, metadata, template_pool_index);

// Extract the result and update pool index if host bindings were compiled
let (host_binding_result, host_binding_next_pool_index) = match host_binding_output {
Some(output) => (Some(output.result), Some(output.next_pool_index)),
None => (None, None),
};
let (host_binding_result, host_binding_next_pool_index, host_binding_declarations) =
match host_binding_output {
Some(output) => {
let declarations = output.result.declarations;
let result = HostBindingCompilationResult {
host_binding_fn: output.result.host_binding_fn,
host_attrs: output.result.host_attrs,
host_vars: output.result.host_vars,
declarations: OxcVec::new_in(allocator),
};
(Some(result), Some(output.next_pool_index), declarations)
}
None => (None, None, OxcVec::new_in(allocator)),
};

// Stage 7: Generate ɵcmp/ɵfac definitions
// The namespace registry is shared across all components in the file to ensure
Expand All @@ -1599,6 +1609,11 @@ fn compile_component_full<'a>(
declarations_js.push_str(&emitter.emit_statement(decl));
declarations_js.push('\n');
}
// Emit host binding declarations (pooled constants like pure functions)
for decl in host_binding_declarations.iter() {
declarations_js.push_str(&emitter.emit_statement(decl));
declarations_js.push('\n');
}

// For HMR, we emit the template separately using compile_template_to_js
// The ɵcmp already contains the template function inline
Expand Down Expand Up @@ -1972,6 +1987,11 @@ pub fn compile_template_to_js_with_options<'a>(
component_name,
options.selector.as_deref(),
) {
// Add host binding pool declarations (pure functions, etc.)
for decl in host_result.declarations {
all_statements.push(decl);
}

// Add the host bindings function as a declaration if present
if let Some(host_fn) = host_result.host_binding_fn {
if let Some(fn_name) = host_fn.name.clone() {
Expand Down Expand Up @@ -2556,6 +2576,16 @@ fn compile_host_bindings_from_input<'a>(
Some(result)
}

/// Result of compiling host bindings for the linker.
pub struct LinkerHostBindingOutput {
/// The host binding function as JS.
pub fn_js: String,
/// Number of host variables.
pub host_vars: u32,
/// Pool constant declarations (pure functions, etc.) as JS.
pub declarations_js: String,
}

/// Compile host bindings for the linker, returning the emitted JS function + hostVars count.
///
/// This takes host property/listener data extracted from a partial declaration and compiles
Expand All @@ -2566,7 +2596,7 @@ pub fn compile_host_bindings_for_linker(
host_input: &HostMetadataInput,
component_name: &str,
selector: Option<&str>,
) -> Option<(String, u32)> {
) -> Option<LinkerHostBindingOutput> {
let allocator = Allocator::default();
let result =
compile_host_bindings_from_input(&allocator, host_input, component_name, selector)?;
Expand All @@ -2575,12 +2605,19 @@ pub fn compile_host_bindings_for_linker(

let host_vars = result.host_vars.unwrap_or(0);

// Emit host binding pool declarations (pure functions, etc.)
let mut declarations_js = String::new();
for decl in result.declarations.iter() {
declarations_js.push_str(&emitter.emit_statement(decl));
declarations_js.push('\n');
}

let fn_js = result.host_binding_fn.map(|f| {
let expr = OutputExpression::Function(oxc_allocator::Box::new_in(f, &allocator));
emitter.emit_expression(&expr)
})?;

Some((fn_js, host_vars))
Some(LinkerHostBindingOutput { fn_js, host_vars, declarations_js })
}

/// Output from compiling a template for the linker.
Expand Down
23 changes: 17 additions & 6 deletions crates/oxc_angular_compiler/src/directive/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub fn compile_directive_from_metadata<'a>(
pool_starting_index: u32,
) -> DirectiveCompileResult<'a> {
// Build the base directive fields, passing pool_starting_index for host bindings
let (definition_map, next_pool_index) =
let (definition_map, next_pool_index, host_declarations) =
build_base_directive_fields(allocator, metadata, pool_starting_index);

// Add features
Expand All @@ -81,22 +81,30 @@ pub fn compile_directive_from_metadata<'a>(
// Create the expression: ɵɵdefineDirective(definitionMap)
let expression = create_define_directive_call(allocator, definition_map);

DirectiveCompileResult { expression, statements: Vec::new_in(allocator), next_pool_index }
// Convert host binding declarations to statements
let mut statements = Vec::new_in(allocator);
for decl in host_declarations {
statements.push(decl);
}

DirectiveCompileResult { expression, statements, next_pool_index }
}

/// Builds the base directive definition map.
///
/// Corresponds to `baseDirectiveFields()` in Angular's compiler.
///
/// Returns a tuple of (entries, next_pool_index) where next_pool_index is the
/// next available constant pool index after host binding compilation.
/// Returns a tuple of (entries, next_pool_index, host_declarations) where next_pool_index is the
/// next available constant pool index after host binding compilation, and host_declarations
/// contains any pooled constants (pure functions) from host binding compilation.
fn build_base_directive_fields<'a>(
allocator: &'a Allocator,
metadata: &R3DirectiveMetadata<'a>,
pool_starting_index: u32,
) -> (Vec<'a, LiteralMapEntry<'a>>, u32) {
) -> (Vec<'a, LiteralMapEntry<'a>>, u32, oxc_allocator::Vec<'a, OutputStatement<'a>>) {
let mut entries = Vec::new_in(allocator);
let mut next_pool_index = pool_starting_index;
let mut host_declarations = oxc_allocator::Vec::new_in(allocator);

// type: MyDirective
entries.push(LiteralMapEntry {
Expand Down Expand Up @@ -196,6 +204,9 @@ fn build_base_directive_fields<'a>(
quoted: false,
});
}

// Collect host binding pool declarations (pure functions, etc.)
host_declarations = result.declarations;
}
}

Expand Down Expand Up @@ -264,7 +275,7 @@ fn build_base_directive_fields<'a>(
});
}

(entries, next_pool_index)
(entries, next_pool_index, host_declarations)
}

/// Adds features to the definition map.
Expand Down
19 changes: 13 additions & 6 deletions crates/oxc_angular_compiler/src/linker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,7 @@ fn link_component(

// Build the defineComponent properties
let mut parts: Vec<String> = Vec::new();
let mut host_binding_declarations_js = String::new();

// 1. type
parts.push(format!("type: {type_name}"));
Expand Down Expand Up @@ -1398,13 +1399,16 @@ fn link_component(
// through the full Angular expression parser for correct output.
let host_input = extract_host_metadata_input(host_obj);
let selector = get_string_property(meta, "selector");
if let Some((host_fn, host_vars)) =
if let Some(host_output) =
crate::component::compile_host_bindings_for_linker(&host_input, type_name, selector)
{
if host_vars > 0 {
parts.push(format!("hostVars: {host_vars}"));
if host_output.host_vars > 0 {
parts.push(format!("hostVars: {}", host_output.host_vars));
}
parts.push(format!("hostBindings: {}", host_output.fn_js));
if !host_output.declarations_js.is_empty() {
host_binding_declarations_js = host_output.declarations_js;
}
parts.push(format!("hostBindings: {host_fn}"));
}
}

Expand Down Expand Up @@ -1533,8 +1537,11 @@ fn link_component(
let define_component =
format!("{ns}.\u{0275}\u{0275}defineComponent({{ {} }})", parts.join(", "));

// Wrap in IIFE with template declarations
let declarations = &template_output.declarations_js;
// Wrap in IIFE with template and host binding declarations
let mut declarations = template_output.declarations_js;
if !host_binding_declarations_js.is_empty() {
declarations.push_str(&host_binding_declarations_js);
}
if declarations.trim().is_empty() {
Some(define_component)
} else {
Expand Down
88 changes: 41 additions & 47 deletions crates/oxc_angular_compiler/src/output/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1234,11 +1234,10 @@ fn is_nullish_coalesce(expr: &OutputExpression<'_>) -> bool {
/// Escape a string for JavaScript output.
///
/// Uses double quotes to match Angular's output style.
/// Escapes `"`, `\`, `\n`, `\r`, `$` (when requested), ASCII control characters,
/// and all non-ASCII characters (code point > 0x7E) as `\uNNNN` sequences.
/// Characters above the BMP (U+10000+) are encoded as UTF-16 surrogate pairs
/// (`\uXXXX\uXXXX`). This matches TypeScript's emitter behavior, which escapes
/// non-ASCII characters in string literals.
/// Escapes `"`, `\`, `\n`, `\r`, `$` (when requested), and ASCII control characters
/// as `\uNNNN` sequences. Non-ASCII characters (code point > 0x7E) are emitted as
/// raw UTF-8 to match Angular's TypeScript emitter behavior (see `escapeIdentifier`
/// in `abstract_emitter.ts`), which only escapes `'`, `\`, `\n`, `\r`, and `$`.
pub(crate) fn escape_string(input: &str, escape_dollar: bool) -> String {
let mut result = String::with_capacity(input.len() + 2);
result.push('"');
Expand All @@ -1251,18 +1250,14 @@ pub(crate) fn escape_string(input: &str, escape_dollar: bool) -> String {
'$' if escape_dollar => result.push_str("\\$"),
// ASCII printable characters (0x20-0x7E) are emitted literally
c if (' '..='\x7E').contains(&c) => result.push(c),
// Everything else (ASCII control chars, non-ASCII) is escaped as \uNNNN.
// Characters above the BMP are encoded as UTF-16 surrogate pairs.
// DEL (0x7F) is an ASCII control character and must be escaped
'\x7F' => push_unicode_escape(&mut result, 0x7F),
// Non-ASCII characters (> 0x7F) are emitted as raw UTF-8 to match
// Angular's TypeScript emitter, which does not escape them.
c if (c as u32) > 0x7F => result.push(c),
// ASCII control characters (0x00-0x1F) are escaped as \uNNNN.
c => {
let code = c as u32;
if code <= 0xFFFF {
push_unicode_escape(&mut result, code);
} else {
let hi = 0xD800 + ((code - 0x10000) >> 10);
let lo = 0xDC00 + ((code - 0x10000) & 0x3FF);
push_unicode_escape(&mut result, hi);
push_unicode_escape(&mut result, lo);
}
push_unicode_escape(&mut result, c as u32);
}
}
}
Expand Down Expand Up @@ -1514,35 +1509,35 @@ mod tests {

#[test]
fn test_escape_string_unicode_literals() {
// Non-ASCII characters should be escaped as \uNNNN to match
// TypeScript's emitter behavior.
// Non-ASCII characters should be emitted as raw UTF-8 to match
// Angular's TypeScript emitter behavior (escapeIdentifier in abstract_emitter.ts).

// &times; (multiplication sign U+00D7) -> \u00D7
assert_eq!(escape_string("\u{00D7}", false), "\"\\u00D7\"");
// &times; (multiplication sign U+00D7) -> raw UTF-8
assert_eq!(escape_string("\u{00D7}", false), "\"\u{00D7}\"");

// &nbsp; (non-breaking space U+00A0) -> \u00A0
assert_eq!(escape_string("\u{00A0}", false), "\"\\u00A0\"");
// &nbsp; (non-breaking space U+00A0) -> raw UTF-8
assert_eq!(escape_string("\u{00A0}", false), "\"\u{00A0}\"");

// Mixed ASCII and non-ASCII
assert_eq!(escape_string("a\u{00D7}b", false), "\"a\\u00D7b\"");
assert_eq!(escape_string("a\u{00D7}b", false), "\"a\u{00D7}b\"");

// Multiple non-ASCII characters
assert_eq!(escape_string("\u{00D7}\u{00A0}", false), "\"\\u00D7\\u00A0\"");
assert_eq!(escape_string("\u{00D7}\u{00A0}", false), "\"\u{00D7}\u{00A0}\"");

// Characters outside BMP (emoji) -> surrogate pair
assert_eq!(escape_string("\u{1F600}", false), "\"\\uD83D\\uDE00\"");
// Characters outside BMP (emoji) -> raw UTF-8
assert_eq!(escape_string("\u{1F600}", false), "\"\u{1F600}\"");

// Common HTML entities -> all escaped as \uNNNN
assert_eq!(escape_string("\u{00A9}", false), "\"\\u00A9\""); // &copy; ©
assert_eq!(escape_string("\u{00AE}", false), "\"\\u00AE\""); // &reg; ®
assert_eq!(escape_string("\u{2014}", false), "\"\\u2014\""); // &mdash; —
assert_eq!(escape_string("\u{2013}", false), "\"\\u2013\""); // &ndash; –
// Common HTML entities -> all emitted as raw UTF-8
assert_eq!(escape_string("\u{00A9}", false), "\"\u{00A9}\""); // &copy; ©
assert_eq!(escape_string("\u{00AE}", false), "\"\u{00AE}\""); // &reg; ®
assert_eq!(escape_string("\u{2014}", false), "\"\u{2014}\""); // &mdash; —
assert_eq!(escape_string("\u{2013}", false), "\"\u{2013}\""); // &ndash; –

// Greek letter alpha
assert_eq!(escape_string("\u{03B1}", false), "\"\\u03B1\""); // α
assert_eq!(escape_string("\u{03B1}", false), "\"\u{03B1}\""); // α

// Accented Latin letter
assert_eq!(escape_string("\u{00E9}", false), "\"\\u00E9\""); // é
assert_eq!(escape_string("\u{00E9}", false), "\"\u{00E9}\""); // é
}

#[test]
Expand All @@ -1561,34 +1556,33 @@ mod tests {
}

#[test]
fn test_escape_string_non_ascii_as_unicode_escapes() {
// Non-ASCII characters should be escaped as \uNNNN to match
// TypeScript's emitter behavior (which escapes non-ASCII in string literals).
fn test_escape_string_non_ascii_as_raw_utf8() {
// Non-ASCII characters should be emitted as raw UTF-8 to match
// Angular's TypeScript emitter behavior (escapeIdentifier in abstract_emitter.ts).

// Non-breaking space U+00A0
assert_eq!(escape_string("\u{00A0}", false), "\"\\u00A0\"");
assert_eq!(escape_string("\u{00A0}", false), "\"\u{00A0}\"");

// En dash U+2013
assert_eq!(escape_string("\u{2013}", false), "\"\\u2013\"");
assert_eq!(escape_string("\u{2013}", false), "\"\u{2013}\"");

// Trademark U+2122
assert_eq!(escape_string("\u{2122}", false), "\"\\u2122\"");
assert_eq!(escape_string("\u{2122}", false), "\"\u{2122}\"");

// Infinity U+221E
assert_eq!(escape_string("\u{221E}", false), "\"\\u221E\"");
assert_eq!(escape_string("\u{221E}", false), "\"\u{221E}\"");

// Mixed ASCII and non-ASCII
assert_eq!(escape_string("a\u{00D7}b", false), "\"a\\u00D7b\"");
assert_eq!(escape_string("a\u{00D7}b", false), "\"a\u{00D7}b\"");

// Multiple non-ASCII characters
assert_eq!(escape_string("\u{00D7}\u{00A0}", false), "\"\\u00D7\\u00A0\"");
assert_eq!(escape_string("\u{00D7}\u{00A0}", false), "\"\u{00D7}\u{00A0}\"");

// Characters above BMP should use surrogate pairs
// U+1F600 (grinning face) = surrogate pair D83D DE00
assert_eq!(escape_string("\u{1F600}", false), "\"\\uD83D\\uDE00\"");
// Characters above BMP (emoji) -> raw UTF-8
assert_eq!(escape_string("\u{1F600}", false), "\"\u{1F600}\"");

// U+10000 (first supplementary char) = surrogate pair D800 DC00
assert_eq!(escape_string("\u{10000}", false), "\"\\uD800\\uDC00\"");
// U+10000 (first supplementary char) -> raw UTF-8
assert_eq!(escape_string("\u{10000}", false), "\"\u{10000}\"");

// ASCII printable chars (0x20-0x7E) should remain literal
assert_eq!(escape_string(" ~", false), "\" ~\"");
Expand Down
Loading
Loading