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
25 changes: 25 additions & 0 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions crates/oxc_angular_compiler/src/output/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
6 changes: 6 additions & 0 deletions crates/oxc_angular_compiler/src/pipeline/compilation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ pub struct ComponentCompilationJob<'a> {
pub has_hoisted_arrow_functions: bool,
/// Diagnostics collected during compilation.
pub diagnostics: std::vec::Vec<OxcDiagnostic>,
/// 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<OxcDiagnostic>,
}

impl<'a> ComponentCompilationJob<'a> {
Expand Down Expand Up @@ -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(),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -475,6 +477,7 @@ fn collect_message<'a>(
file_suffix: &str,
use_external_ids: bool,
counter: &mut usize,
errors: &mut std::vec::Vec<OxcDiagnostic>,
) -> (String, ArenaVec<'a, OutputStatement<'a>>) {
let mut all_statements = ArenaVec::new_in(&allocator);

Expand All @@ -494,6 +497,7 @@ fn collect_message<'a>(
file_suffix,
use_external_ids,
counter,
errors,
);
all_statements.extend(sub_statements);

Expand Down Expand Up @@ -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| {
Expand Down
70 changes: 69 additions & 1 deletion crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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#"<p i18n="m|d */ steal() /*">Hello</p>"#,
r#"<img i18n-alt="*/ } steal(); { /*|d" alt="Logo" />"#,
r#"<p i18n="m /* x|d">Hello</p>"#,
] {
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<String> = 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#"<p i18n="*/x"></p>"#, &full),
(r#"<p i18n="*/x"> </p>"#, &full),
(r#"<p i18n="*/x"><!-- c --></p>"#, &full),
(r#"<ng-container i18n="*/x"></ng-container>"#, &full),
(r#"<ng-content i18n="*/x"></ng-content>"#, &full),
(r#"<p title="a" i18n-title="*/x" i18n-title="ok">x</p>"#, &full),
(r#"<img i18n-alt="*/x" />"#, &full),
(r#"<img i18n-alt="*/x" [alt]="alt" />"#, &full),
(r#"<p i18n="m|d */ steal() /*">Hello</p>"#, &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#"<p i18n="m@x|mail a@b.c">Hello</p>"#);
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 [...]; }
Expand Down
Loading