From a88a106c34a941cbb19e6c1545f7910c6d39d256 Mon Sep 17 00:00:00 2001 From: Gabriel Silva Date: Mon, 26 Oct 2026 19:23:51 -0300 Subject: [PATCH 1/2] fix(i18n): reject a meaning or description that would close its JSDoc comment, and escape `@` in it --- .../src/output/emitter.rs | 5 +-- .../src/transform/html_to_r3.rs | 11 ++++++ .../tests/integration_test.rs | 34 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) 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/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index 90aa20bd4..9df7361ec 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -627,6 +627,7 @@ impl<'a> HtmlToR3Transform<'a> { let i18n_meta = if let Some(attr) = i18n_attr { // Element has its own i18n attribute - parse it as a Message with message string let instance_id = self.allocate_i18n_message_instance_id(); + self.check_i18n_meta_comment(attr.value.as_str(), attr.span); let mut meta = parse_i18n_meta_with_message( &self.allocator, attr.value.as_str(), @@ -3327,6 +3328,7 @@ impl<'a> HtmlToR3Transform<'a> { let instance_id = self.allocate_i18n_message_instance_id(); let (message_string, nodes, mut legacy_message) = self.create_attribute_i18n_message(target); + self.check_i18n_meta_comment(attr.value.as_str(), attr.span); let mut meta = parse_i18n_meta_with_message( self.allocator, attr.value.as_str(), @@ -5152,6 +5154,15 @@ impl<'a> HtmlToR3Transform<'a> { )) } + /// Reports an `i18n` / `i18n-*` meaning or description that would end the JSDoc comment the + /// message is emitted under, or start one, as Angular's `tagToString` throws on it. + fn check_i18n_meta_comment(&mut self, value: &str, span: Span) { + let ParsedI18nMeta { meaning, description, .. } = parse_i18n_meta(value); + if [meaning, description].iter().any(|text| text.contains("/*") || text.contains("*/")) { + self.report_error(r#"JSDoc text cannot contain "/*" and "*/""#, span); + } + } + /// Reports a parse error at the given span. fn report_error(&mut self, msg: &str, span: Span) { self.errors.push(crate::util::ParseError { diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 1fbebb75a..4ee07fad5 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -5902,6 +5902,40 @@ 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); + } +} + +/// `@` 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 [...]; } From a85febf897d4f176af998482dd35232136f9c7f8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 28 Oct 2026 11:34:38 +0800 Subject: [PATCH 2/2] fix(i18n): report a `*/` meaning or description only when its JSDoc is printed Angular's `tagToString` throws when it prints the JSDoc, so `ngc` compiles a template whose `*/` meta has no message: an empty or comment-only `i18n` element, ``, an `i18n-title` that a later one replaces, an `i18n-alt` with no value, and partial compilation. The check now runs where the message's JSDoc is built and goes out as an error from full, template, HMR and linker compilation. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 25 +++++++++++++ .../src/pipeline/compilation.rs | 6 ++++ .../pipeline/phases/i18n_const_collection.rs | 14 ++++++++ .../src/transform/html_to_r3.rs | 11 ------ .../tests/integration_test.rs | 36 ++++++++++++++++++- 5 files changed, 80 insertions(+), 12 deletions(-) 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/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/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index 9df7361ec..90aa20bd4 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -627,7 +627,6 @@ impl<'a> HtmlToR3Transform<'a> { let i18n_meta = if let Some(attr) = i18n_attr { // Element has its own i18n attribute - parse it as a Message with message string let instance_id = self.allocate_i18n_message_instance_id(); - self.check_i18n_meta_comment(attr.value.as_str(), attr.span); let mut meta = parse_i18n_meta_with_message( &self.allocator, attr.value.as_str(), @@ -3328,7 +3327,6 @@ impl<'a> HtmlToR3Transform<'a> { let instance_id = self.allocate_i18n_message_instance_id(); let (message_string, nodes, mut legacy_message) = self.create_attribute_i18n_message(target); - self.check_i18n_meta_comment(attr.value.as_str(), attr.span); let mut meta = parse_i18n_meta_with_message( self.allocator, attr.value.as_str(), @@ -5154,15 +5152,6 @@ impl<'a> HtmlToR3Transform<'a> { )) } - /// Reports an `i18n` / `i18n-*` meaning or description that would end the JSDoc comment the - /// message is emitted under, or start one, as Angular's `tagToString` throws on it. - fn check_i18n_meta_comment(&mut self, value: &str, span: Span) { - let ParsedI18nMeta { meaning, description, .. } = parse_i18n_meta(value); - if [meaning, description].iter().any(|text| text.contains("/*") || text.contains("*/")) { - self.report_error(r#"JSDoc text cannot contain "/*" and "*/""#, span); - } - } - /// Reports a parse error at the given span. fn report_error(&mut self, msg: &str, span: Span) { self.errors.push(crate::util::ParseError { diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 4ee07fad5..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, @@ -5928,6 +5929,39 @@ fn test_i18n_meta_that_would_close_its_jsdoc_comment_is_an_error() { } } +/// 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]