diff --git a/crates/oxc_angular_compiler/src/browser.rs b/crates/oxc_angular_compiler/src/browser.rs new file mode 100644 index 000000000..419ca1d3c --- /dev/null +++ b/crates/oxc_angular_compiler/src/browser.rs @@ -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, + pub errors: Vec, +} + +/// 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}"); + } +} diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index a34f97c69..009aec7ef 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -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; diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 4f1b6fda8..a1bdd9cf2 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -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 +} + /** Result of class metadata compilation. */ export interface ClassMetadataNapiCompileResult { /** @@ -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 + +/** + * 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. */ diff --git a/napi/angular-compiler/index.js b/napi/angular-compiler/index.js index f5ebb7a14..352c63908 100644 --- a/napi/angular-compiler/index.js +++ b/napi/angular-compiler/index.js @@ -926,6 +926,8 @@ const { parseComponentId, transformAngularFile, transformAngularFileSync, + transformForBrowser, + transformForBrowserSync, Severity, } = nativeBinding export { compileClassMetadata } @@ -948,5 +950,7 @@ export { optimizeAngularPackage } export { parseComponentId } export { transformAngularFile } export { transformAngularFileSync } +export { transformForBrowser } +export { transformForBrowserSync } export { Severity } export const __napiBindingTarget = __napiLoadedBindingTarget diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 8e838932f..a1f77e107 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -3008,3 +3008,86 @@ pub fn link_angular_package( ) -> AsyncTask { 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, + /// `async`/`await`, async generators and `for await` as generators, for zone.js (default: true) + pub lower_async: Option, +} + +/// Result of `transformForBrowser`. +#[napi(object)] +pub struct BrowserTransformResult { + pub code: String, + pub map: Option, + pub errors: Vec, +} + +/// 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, +) -> 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, +} + +#[napi] +impl Task for TransformForBrowserTask { + type JsValue = BrowserTransformResult; + type Output = BrowserTransformResult; + + fn compute(&mut self) -> napi::Result { + 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 { + Ok(result) + } +} + +/// `transformForBrowserSync` on a worker thread +#[napi] +pub fn transform_for_browser( + code: String, + filename: String, + options: Option, +) -> AsyncTask { + AsyncTask::new(TransformForBrowserTask { code, filename, options }) +} diff --git a/napi/angular-compiler/test/transform-for-browser.test.ts b/napi/angular-compiler/test/transform-for-browser.test.ts new file mode 100644 index 000000000..b489a34ab --- /dev/null +++ b/napi/angular-compiler/test/transform-for-browser.test.ts @@ -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) + }) +})