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
40 changes: 36 additions & 4 deletions crates/oxc_angular_compiler/src/directive/decorator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use oxc_ast::ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_span::{GetSpan, Span};
use oxc_str::Ident;
use oxc_syntax::number::ToJsString;
use oxc_syntax::operator::UnaryOperator;

use super::evaluator::{Evaluator, FileScope, Prop, RefKind, Value, transform_error};
use super::metadata::{
Expand Down Expand Up @@ -512,6 +514,8 @@ fn has_ng_on_changes_method(class: &Class<'_>) -> bool {
#[derive(Default)]
pub struct StringConsts<'a> {
strings: HashMap<&'a str, Ident<'a>>,
/// `const NAME = 123`: numbers a template literal's `${...}` can interpolate.
numbers: HashMap<&'a str, f64>,
program: Option<&'a Program<'a>>,
/// Built on first use: most files have no decorator metadata to evaluate.
scope: std::cell::OnceCell<FileScope<'a>>,
Expand Down Expand Up @@ -557,6 +561,7 @@ impl<'a> StringConsts<'a> {
pub(crate) fn declarations_of(program: &'a Program<'a>) -> Self {
Self {
strings: HashMap::default(),
numbers: HashMap::default(),
program: Some(program),
scope: std::cell::OnceCell::new(),
#[cfg(feature = "cross_file_elision")]
Expand Down Expand Up @@ -616,6 +621,10 @@ pub fn collect_string_consts<'a>(

let mut map = StringConsts {
strings: HashMap::default(),
numbers: pending
.iter()
.filter_map(|(name, init)| Some((*name, numeric_literal_value(init)?)))
.collect(),
Comment thread
Brooooooklyn marked this conversation as resolved.
program: Some(program),
scope: std::cell::OnceCell::new(),
#[cfg(feature = "cross_file_elision")]
Expand Down Expand Up @@ -654,9 +663,23 @@ fn literal_string_from_expression<'a>(expr: &Expression<'a>) -> Option<Ident<'a>
}
}

/// The value of a number literal (`150`, `-1.5`).
fn numeric_literal_value(expr: &Expression<'_>) -> Option<f64> {
match expr {
Expression::NumericLiteral(lit) => Some(lit.value),
Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
match &unary.argument {
Expression::NumericLiteral(lit) => Some(-lit.value),
_ => None,
}
}
_ => None,
}
}

/// Fold a template literal whose `${...}` interpolations reference known
/// string consts. Returns `None` if any interpolation can't be statically
/// resolved to a string — matching Angular's all-or-nothing partial evaluator
/// string consts, number consts or number literals. Returns `None` if any
/// interpolation can't be statically resolved to a string — matching Angular's all-or-nothing partial evaluator
/// for static metadata fields.
pub(crate) fn resolve_template_literal<'a>(
allocator: &'a Allocator,
Expand All @@ -673,8 +696,17 @@ pub(crate) fn resolve_template_literal<'a>(
let cooked = quasi.value.cooked?.as_str()?;
buf.push_str(cooked);
if let Some(expr) = tpl.expressions.get(i) {
let resolved = extract_string_value(allocator, expr, consts)?;
buf.push_str(resolved.as_str());
if let Some(resolved) = extract_string_value(allocator, expr, consts) {
buf.push_str(resolved.as_str());
} else {
// `${150}` or `${WIDTH}` with `const WIDTH = 150`: the number as
// JavaScript converts it to a string
let number = match expr {
Expression::Identifier(id) => *consts.numbers.get(id.name.as_str())?,
_ => numeric_literal_value(expr)?,
};
buf.push_str(&number.to_js_string());
}
}
}
Some(Ident::from(allocator.alloc_str(&buf)))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! A `${...}` interpolation in an inline template can be a number, a literal or
//! a same-file `const` (`const WIDTH = 150`), as well as a string: ngtsc's
//! partial evaluator folds it as JavaScript converts the number to a string.
//! Before, the component was left uncompiled. The expected `consts` are
//! `@angular/compiler-cli` 21.0.1's for the same components.

use oxc_allocator::Allocator;
use oxc_angular_compiler::{TransformOptions, transform_angular_file};

fn compile(source: &str) -> String {
let allocator = Allocator::default();
let result = transform_angular_file(
&allocator,
"test.ts",
source,
Some(&TransformOptions::default()),
None,
);
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
result.code.chars().filter(|c| !c.is_whitespace()).collect()
}

#[test]
fn number_consts_in_a_template_literal() {
let code = compile(
r#"
import { Component } from '@angular/core';
const WIDTH = 150;
const HEIGHT = 80;
const CLS = 'box';
@Component({ selector: 'e-cmp', template: `<canvas class="${CLS}" style="width: ${WIDTH}px; height: ${HEIGHT}px;"></canvas>` })
export class ECmp {}
"#,
);
assert!(
code.contains(r#"consts:[[1,"box",2,"width","150px","height","80px"]]"#),
"Got:\n{code}"
);
}

#[test]
fn chained_and_negative_number_consts_and_number_literals() {
let code = compile(
r#"
import { Component } from '@angular/core';
const WIDTH = 150;
const SIZE = `${WIDTH}px`;
const OFFSET = -2;
@Component({ selector: 'e3-cmp', template: `<div style="width: ${SIZE}; margin: ${OFFSET}px; opacity: ${0.5};"></div>` })
export class E3Cmp {}
"#,
);
assert!(
code.contains(r#"consts:[[2,"width","150px","margin","-2px","opacity","0.5"]]"#),
"Got:\n{code}"
);
}
Loading