diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 097f5254f..ea6984a72 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5822,6 +5822,11 @@ fn compile_component_full<'a>( let compiled = compile_template(&mut job); + if !job.emit_errors.is_empty() { + diagnostics.append(&mut job.emit_errors); + return Err(diagnostics); + } + // Stage 6: Compile host bindings (if any) // Pass the template pool's current index to ensure host binding constants // continue from where template compilation left off (avoiding duplicate names) @@ -6203,6 +6208,11 @@ pub fn compile_component_template<'a>( // Collect any diagnostics from the compilation job diagnostics.extend(job.diagnostics.into_iter()); + if !job.emit_errors.is_empty() { + diagnostics.append(&mut job.emit_errors); + return Err(diagnostics); + } + Ok((compiled.template_fn, diagnostics)) } @@ -6338,6 +6348,11 @@ pub fn compile_template_to_js_with_options<'a>( // Collect any diagnostics from the compilation job diagnostics.extend(job.diagnostics.into_iter()); + if !job.emit_errors.is_empty() { + diagnostics.append(&mut job.emit_errors); + return Err(diagnostics); + } + let emitter = JsEmitter::new(); // Build a list of all statements to emit: @@ -6528,6 +6543,11 @@ pub fn compile_template_for_hmr<'a>( // Collect any diagnostics from the compilation job diagnostics.extend(job.diagnostics.into_iter()); + if !job.emit_errors.is_empty() { + diagnostics.append(&mut job.emit_errors); + return Err(diagnostics); + } + // A pipeline error means the emitted code is already wrong — an update // module must not be generated from it, and `decls`/`vars` may not be // reliable (they default to 0 when slot allocation never ran, which would @@ -7278,6 +7298,11 @@ pub fn compile_template_for_linker<'a>( // Collect diagnostics diagnostics.extend(job.diagnostics.into_iter()); + if !job.emit_errors.is_empty() { + diagnostics.append(&mut job.emit_errors); + return Err(diagnostics); + } + // Extract numeric metadata from the compilation job let decls = job.root.decl_count.unwrap_or(0); let vars = job.root.vars.unwrap_or(0); diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index b4d8084ff..09f7bb3ee 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -517,13 +517,14 @@ impl JsEmitter { // Format JSDoc comment with @desc, @meaning, and @suppress tags // See: packages/compiler/src/output/output_jit_trusted_types.ts ctx.print("/**"); + // `@` in the text is escaped, as Angular's `tagToString` does, so it starts no tag. if let Some(ref desc) = jsdoc.description { ctx.print(" @desc "); - ctx.print(desc.as_str()); + ctx.print(&desc.as_str().replace('@', "\\@")); } if let Some(ref meaning) = jsdoc.meaning { ctx.print(" @meaning "); - ctx.print(meaning.as_str()); + ctx.print(&meaning.as_str().replace('@', "\\@")); } if jsdoc.suppress_msg_descriptions { ctx.print(" @suppress {msgDescriptions}"); diff --git a/crates/oxc_angular_compiler/src/pipeline/compilation.rs b/crates/oxc_angular_compiler/src/pipeline/compilation.rs index 5f5461ef5..5176c5643 100644 --- a/crates/oxc_angular_compiler/src/pipeline/compilation.rs +++ b/crates/oxc_angular_compiler/src/pipeline/compilation.rs @@ -235,6 +235,11 @@ pub struct ComponentCompilationJob<'a> { pub has_hoisted_arrow_functions: bool, /// Diagnostics collected during compilation. pub diagnostics: std::vec::Vec, + /// Errors that make the generated code unsafe to emit. + /// + /// Unlike `diagnostics`, which full compilation drops, every entry point returns these as an + /// error. + pub emit_errors: std::vec::Vec, } impl<'a> ComponentCompilationJob<'a> { @@ -287,6 +292,7 @@ impl<'a> ComponentCompilationJob<'a> { core_namespace: Ident::from(crate::r3::CORE_ALIAS), has_hoisted_arrow_functions: false, diagnostics: std::vec::Vec::new(), + emit_errors: std::vec::Vec::new(), } } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs index 4a30599a6..356354fea 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs @@ -8,6 +8,7 @@ use std::ptr::NonNull; use oxc_allocator::Vec as ArenaVec; +use oxc_diagnostics::OxcDiagnostic; use oxc_str::Ident; use rustc_hash::FxHashMap; @@ -202,6 +203,7 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { &file_based_i18n_suffix, job.i18n_use_external_ids, &mut i18n_var_counter, + &mut job.emit_errors, ); if let Some(block_xref) = msg_info.i18n_block { @@ -475,6 +477,7 @@ fn collect_message<'a>( file_suffix: &str, use_external_ids: bool, counter: &mut usize, + errors: &mut std::vec::Vec, ) -> (String, ArenaVec<'a, OutputStatement<'a>>) { let mut all_statements = ArenaVec::new_in(&allocator); @@ -494,6 +497,7 @@ fn collect_message<'a>( file_suffix, use_external_ids, counter, + errors, ); all_statements.extend(sub_statements); @@ -542,6 +546,16 @@ fn collect_message<'a>( ); *counter += 1; + // The meaning and description go into a JSDoc comment, so, as Angular's `tagToString` throws, + // neither may end that comment or start another. + if [&msg_info.description, &msg_info.meaning] + .into_iter() + .flatten() + .any(|text| text.contains("/*") || text.contains("*/")) + { + errors.push(OxcDiagnostic::error(r#"JSDoc text cannot contain "/*" and "*/""#)); + } + // Create metadata for JSDoc let meta = if msg_info.description.is_some() || msg_info.meaning.is_some() { let desc = msg_info.description.as_ref().map(|d| { diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 1fbebb75a..e52261f76 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -5,7 +5,8 @@ use oxc_allocator::Allocator; use oxc_angular_compiler::{ - AngularVersion, R3Node, ResolvedResources, TransformOptions as ComponentTransformOptions, + AngularVersion, CompilationMode, R3Node, ResolvedResources, + TransformOptions as ComponentTransformOptions, output::ast::FunctionExpr, output::emitter::JsEmitter, parser::html::HtmlParser, @@ -5902,6 +5903,73 @@ fn test_i18n_legacy_message_ids_untrimmed_attribute_and_nested_icu() { assert_contains(&js, ":ICU@@5870918674674044418:"); } +/// A meaning or description goes in a JSDoc comment, so `*/` in it would end the comment and +/// leave the rest as code. Angular's `tagToString` throws `JSDoc text cannot contain "/*" and +/// "*/"`, and the template is reported with that error instead of compiled. +#[test] +fn test_i18n_meta_that_would_close_its_jsdoc_comment_is_an_error() { + for template in [ + r#"

Hello

"#, + r#"Logo"#, + r#"

Hello

"#, + ] { + let allocator = Allocator::default(); + let source = format!( + "import {{ Component }} from '@angular/core';\n\ + @Component({{ selector: 'app-repro', template: `{template}` }})\n\ + export class Repro {{}}\n" + ); + let result = transform_angular_file(&allocator, "repro.ts", &source, None, None); + let messages: Vec = result.diagnostics.iter().map(|d| d.to_string()).collect(); + assert!( + messages.iter().any(|m| m.contains(r#"JSDoc text cannot contain "/*" and "*/""#)), + "{template}: {messages:?}" + ); + assert!(!result.code.contains("/**"), "{}", result.code); + } +} + +/// Angular throws only when it prints the JSDoc, so `*/` is fine where no message is emitted, +/// and in partial compilation, which emits the template source. Angular 22.2 `ngc` compiles +/// each of these. +#[test] +fn test_i18n_meta_that_would_close_its_jsdoc_comment_is_fine_with_no_message() { + let full = ComponentTransformOptions::default(); + let partial = ComponentTransformOptions { + compilation_mode: CompilationMode::Partial, + ..ComponentTransformOptions::default() + }; + for (template, options) in [ + (r#"

"#, &full), + (r#"

"#, &full), + (r#"

"#, &full), + (r#""#, &full), + (r#""#, &full), + (r#"

x

"#, &full), + (r#""#, &full), + (r#""#, &full), + (r#"

Hello

"#, &partial), + ] { + let allocator = Allocator::default(); + let source = format!( + "import {{ Component }} from '@angular/core';\n\ + @Component({{ selector: 'app-repro', template: `{template}` }})\n\ + export class Repro {{ alt = ''; }}\n" + ); + let result = transform_angular_file(&allocator, "repro.ts", &source, Some(options), None); + assert!(!result.has_errors(), "{template}: {:?}", result.diagnostics); + assert!(result.code.contains("Repro"), "{}", result.code); + } +} + +/// `@` in a meaning or description is escaped, as Angular's `tagToString` does, so it does not +/// start a JSDoc tag. +#[test] +fn test_i18n_meta_at_sign_is_escaped_in_jsdoc() { + let js = compile_i18n_component(r#"

Hello

"#); + assert_contains(&js, r"/** @desc mail a\@b.c @meaning m\@x */"); +} + /// `consts` with i18n initializers is an arrow function with a block body, and a message is /// a native `$localize` tagged template. Angular 22.2 `ngc` (ES2022): /// consts: () => { let i18n_0; ... i18n_0 = $localize `:@@close:Close`; } return [...]; }