Skip to content
Open
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
148 changes: 148 additions & 0 deletions crates/oxc_angular_compiler/src/browser.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! What a bundler without per-feature lowering needs for an Angular app that runs zone.js: TypeScript stripped as
//! `tsc` does it under Angular's settings, and `async`/`await` turned into generators, because zone.js cannot see
//! native `await` (the Angular CLI does the same through esbuild's `supported` option).

use std::path::{Path, PathBuf};

use oxc_allocator::Allocator;
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use oxc_transformer::{
ClassPropertiesOptions, CompilerAssumptions, DecoratorOptions, ES2017Options, ES2018Options,
ES2022Options, EnvOptions, HelperLoaderMode, HelperLoaderOptions, TransformOptions,
Transformer, TypeScriptOptions,
};

#[derive(Debug, Clone, Default)]
pub struct BrowserTransformOptions {
pub sourcemap: bool,
/// `async` functions and generators, and `for await`, as generators (helpers from `@oxc-project/runtime`)
pub lower_async: bool,
}

#[derive(Debug, Default)]
pub struct BrowserTransformResult {
pub code: String,
pub map: Option<String>,
pub errors: Vec<String>,
}

/// TypeScript files get `experimentalDecorators` and `useDefineForClassFields: false` semantics (fields assigned in
/// the constructor, after parameter properties; statics assigned after the class), as Angular's tsconfig has them.
/// JavaScript keeps its class fields.
pub fn transform_for_browser(
allocator: &Allocator,
code: &str,
filename: &str,
options: &BrowserTransformOptions,
) -> BrowserTransformResult {
let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::mjs());
let parsed = Parser::new(allocator, code, source_type).parse();
if parsed.fatal_error || parsed.diagnostics.has_errors() {
return BrowserTransformResult {
errors: parsed.diagnostics.errors().map(|e| e.to_string()).collect(),
..Default::default()
};
}
let mut program = parsed.program;
let typescript = source_type.is_typescript();

let mut env = EnvOptions::default();
if options.lower_async {
env.es2017 = ES2017Options { async_to_generator: true };
env.es2018 = ES2018Options { async_generator_functions: true, ..env.es2018 };
}
if typescript {
env.es2022 = ES2022Options {
class_properties: Some(ClassPropertiesOptions { loose: true }),
..env.es2022
};
}
let transform_options = TransformOptions {
cwd: PathBuf::new(),
typescript: TypeScriptOptions {
remove_class_fields_without_initializer: true,
..Default::default()
},
assumptions: CompilerAssumptions { set_public_class_fields: true, ..Default::default() },
decorator: DecoratorOptions { legacy: typescript, ..Default::default() },
env,
helper_loader: HelperLoaderOptions {
mode: HelperLoaderMode::Runtime,
..Default::default()
},
..Default::default()
};
let scoping =
SemanticBuilder::new().with_excess_capacity(2.0).build(&program).semantic.into_scoping();
let transformed = Transformer::new(allocator, Path::new(filename), &transform_options)
.build_with_scoping(scoping, &mut program);
if transformed.diagnostics.has_errors() {
return BrowserTransformResult {
errors: transformed.diagnostics.errors().map(|e| e.to_string()).collect(),
..Default::default()
};
}

let codegen = Codegen::new()
.with_options(CodegenOptions {
source_map_path: options.sourcemap.then(|| PathBuf::from(filename)),
..CodegenOptions::default()
})
.with_source_text(code)
.build(&program);
BrowserTransformResult {
code: codegen.code,
map: codegen.map.map(|m| m.to_json_string()),
errors: Vec::new(),
}
}

#[cfg(test)]
mod tests {
use super::*;

fn run(code: &str, filename: &str) -> String {
let allocator = Allocator::default();
let r = transform_for_browser(
&allocator,
code,
filename,
&BrowserTransformOptions { sourcemap: false, lower_async: true },
);
assert!(r.errors.is_empty(), "{:?}", r.errors);
r.code
}

#[test]
fn lowers_async_and_for_await() {
let out = run(
"export async function f(xs) { for await (const x of xs) await x; }\nexport async function* g() { yield 1; }",
"a.mjs",
);
assert!(!out.contains("async function"), "{out}");
assert!(!out.contains("for await"), "{out}");
assert!(out.contains("@oxc-project/runtime/helpers/asyncToGenerator"), "{out}");
}

#[test]
fn typescript_fields_are_assigned_after_parameter_properties() {
let out = run(
"export class A { b = this.a.x; static s = 1; c: number; constructor(private a: { x: number }) {} }",
"a.ts",
);
let a = out.find("this.a = a").expect(&out);
let b = out.find("this.b = this.a.x").expect(&out);
assert!(a < b, "{out}");
assert!(out.contains("A.s = 1"), "{out}");
assert!(!out.contains("this.c"), "{out}");
}

#[test]
fn javascript_keeps_class_fields() {
let out = run("export class A { b = 1; static s = 2; }", "a.mjs");
assert!(out.contains("b = 1;") && out.contains("static s = 2"), "{out}");
}
}
1 change: 1 addition & 0 deletions crates/oxc_angular_compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod util;
mod validation;

pub mod ast;
pub mod browser;
pub mod class_debug_info;
pub mod class_metadata;
pub mod compilation_mode;
Expand Down
33 changes: 33 additions & 0 deletions napi/angular-compiler/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ export interface AngularVersion {
prerelease?: string
}

/** Options for `transformForBrowser`. */
export interface BrowserTransformOptions {
/** Generate a source map (default: false) */
sourcemap?: boolean
/** `async`/`await`, async generators and `for await` as generators, for zone.js (default: true) */
lowerAsync?: boolean
}

/** Result of `transformForBrowser`. */
export interface BrowserTransformResult {
code: string
map?: string
errors: Array<string>
}

/** Result of class metadata compilation. */
export interface ClassMetadataNapiCompileResult {
/**
Expand Down Expand Up @@ -855,6 +870,24 @@ export declare function transformAngularFileSync(
resolvedResources?: ResolvedResources | undefined | null,
): TransformResult

/** `transformForBrowserSync` on a worker thread */
export declare function transformForBrowser(
code: string,
filename: string,
options?: BrowserTransformOptions | undefined | null,
): Promise<BrowserTransformResult>

/**
* TypeScript stripped with Angular's settings (`experimentalDecorators`, `useDefineForClassFields: false`) and
* `async` lowered to generators, which zone.js needs; JavaScript gets only the lowering. Runtime helpers are
* imported from `@oxc-project/runtime`.
*/
export declare function transformForBrowserSync(
code: string,
filename: string,
options?: BrowserTransformOptions | undefined | null,
): BrowserTransformResult

/** Options for transforming an Angular component. */
export interface TransformOptions {
/** Generate source maps. */
Expand Down
4 changes: 4 additions & 0 deletions napi/angular-compiler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,8 @@ const {
parseComponentId,
transformAngularFile,
transformAngularFileSync,
transformForBrowser,
transformForBrowserSync,
Severity,
} = nativeBinding
export { compileClassMetadata }
Expand All @@ -948,5 +950,7 @@ export { optimizeAngularPackage }
export { parseComponentId }
export { transformAngularFile }
export { transformAngularFileSync }
export { transformForBrowser }
export { transformForBrowserSync }
export { Severity }
export const __napiBindingTarget = __napiLoadedBindingTarget
83 changes: 83 additions & 0 deletions napi/angular-compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3008,3 +3008,86 @@ pub fn link_angular_package(
) -> AsyncTask<LinkAngularPackageTask> {
AsyncTask::new(LinkAngularPackageTask { code, filename, options })
}

// =============================================================================
// Browser transform (TypeScript and async lowering for zone.js)
// =============================================================================

/// Options for `transformForBrowser`.
#[napi(object)]
#[derive(Default)]
pub struct BrowserTransformOptions {
/// Generate a source map (default: false)
pub sourcemap: Option<bool>,
/// `async`/`await`, async generators and `for await` as generators, for zone.js (default: true)
pub lower_async: Option<bool>,
}

/// Result of `transformForBrowser`.
#[napi(object)]
pub struct BrowserTransformResult {
pub code: String,
pub map: Option<String>,
pub errors: Vec<String>,
}

/// TypeScript stripped with Angular's settings (`experimentalDecorators`, `useDefineForClassFields: false`) and
/// `async` lowered to generators, which zone.js needs; JavaScript gets only the lowering. Runtime helpers are
/// imported from `@oxc-project/runtime`.
#[napi]
pub fn transform_for_browser_sync(
code: String,
filename: String,
options: Option<BrowserTransformOptions>,
) -> BrowserTransformResult {
use oxc_angular_compiler::browser::{
BrowserTransformOptions as RustOptions, transform_for_browser,
};

let options = options.unwrap_or_default();
let allocator = Allocator::default();
let result = transform_for_browser(
&allocator,
&code,
&filename,
&RustOptions {
sourcemap: options.sourcemap.unwrap_or(false),
lower_async: options.lower_async.unwrap_or(true),
},
);
BrowserTransformResult { code: result.code, map: result.map, errors: result.errors }
}

pub struct TransformForBrowserTask {
code: String,
filename: String,
options: Option<BrowserTransformOptions>,
}

#[napi]
impl Task for TransformForBrowserTask {
type JsValue = BrowserTransformResult;
type Output = BrowserTransformResult;

fn compute(&mut self) -> napi::Result<Self::Output> {
Ok(transform_for_browser_sync(
std::mem::take(&mut self.code),
std::mem::take(&mut self.filename),
self.options.take(),
))
}

fn resolve(&mut self, _: napi::Env, result: Self::Output) -> napi::Result<Self::JsValue> {
Ok(result)
}
}

/// `transformForBrowserSync` on a worker thread
#[napi]
pub fn transform_for_browser(
code: String,
filename: String,
options: Option<BrowserTransformOptions>,
) -> AsyncTask<TransformForBrowserTask> {
AsyncTask::new(TransformForBrowserTask { code, filename, options })
}
51 changes: 51 additions & 0 deletions napi/angular-compiler/test/transform-for-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'

import { transformForBrowser, transformForBrowserSync } from '../index.js'

describe('transformForBrowser', () => {
it('lowers async functions, async generators and for await to generators for zone.js', async () => {
const result = await transformForBrowser(
`export async function load(urls) { for await (const r of urls) await r; }
export async function* pages() { yield 1; }`,
'load.mjs',
)
expect(result.errors).toEqual([])
expect(result.code).not.toMatch(/async function|for await/)
expect(result.code).toContain('@oxc-project/runtime/helpers/asyncToGenerator')
})

it("keeps async native with lowerAsync: false, and leaves JavaScript's class fields alone", () => {
const result = transformForBrowserSync(
`export class A { b = 1; static s = 2; async f() { await 1; } }`,
'a.mjs',
{ lowerAsync: false },
)
expect(result.code).toContain('async f()')
expect(result.code).toMatch(/b = 1;[\s\S]*static s = 2/)
})

it('strips TypeScript with useDefineForClassFields: false: fields assigned after parameter properties', () => {
const { code, errors } = transformForBrowserSync(
`export class A { b = this.a.x; static s = 1; c: number; constructor(private a: { x: number }) {} }`,
'a.ts',
)
expect(errors).toEqual([])
expect(code.indexOf('this.a = a')).toBeLessThan(code.indexOf('this.b = this.a.x'))
expect(code).toContain('A.s = 1')
expect(code).not.toContain('this.c')
})

it('applies legacy decorators to TypeScript', () => {
const { code, errors } = transformForBrowserSync(
`function d(t: any) {}\n@d export class A {}`,
'a.ts',
)
expect(errors).toEqual([])
expect(code).not.toContain('@d')
expect(code).toContain('_decorate')
})

it('reports syntax errors', () => {
expect(transformForBrowserSync('let = ;', 'broken.ts').errors.length).toBeGreaterThan(0)
})
})