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
794 changes: 759 additions & 35 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/oxc_angular_compiler/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ oxc_sourcemap = { workspace = true }
miette = { workspace = true }
rustc-hash = { workspace = true }
indexmap = { workspace = true }
lightningcss = "1.0.0-alpha.71"
oxc_resolver = { version = "11", optional = true }
pathdiff = { version = "0.2", optional = true }
semver = "1.0.27"
Expand Down
32 changes: 15 additions & 17 deletions crates/oxc_angular_compiler/src/component/definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use super::metadata::{
ViewEncapsulation,
};
use super::namespace_registry::NamespaceRegistry;
use super::transform::TransformOptions;
use crate::directive::{
create_host_directive_mappings_array, create_inputs_literal, create_outputs_literal,
};
Expand Down Expand Up @@ -62,6 +63,7 @@ pub struct ComponentDefinitions<'a> {
pub fn generate_component_definitions<'a>(
allocator: &'a Allocator,
metadata: &ComponentMetadata<'a>,
options: &TransformOptions,
job: &mut ComponentCompilationJob<'a>,
template_fn: FunctionExpr<'a>,
host_binding_result: Option<HostBindingCompilationResult<'a>>,
Expand All @@ -79,6 +81,7 @@ pub fn generate_component_definitions<'a>(
let cmp_definition = generate_cmp_definition(
allocator,
metadata,
options,
job,
template_fn,
host_binding_result,
Expand Down Expand Up @@ -109,6 +112,7 @@ pub fn generate_component_definitions<'a>(
fn generate_cmp_definition<'a>(
allocator: &'a Allocator,
metadata: &ComponentMetadata<'a>,
options: &TransformOptions,
job: &mut ComponentCompilationJob<'a>,
template_fn: FunctionExpr<'a>,
host_binding_result: Option<HostBindingCompilationResult<'a>>,
Expand Down Expand Up @@ -435,23 +439,17 @@ fn generate_cmp_definition<'a>(
if !metadata.styles.is_empty() {
let mut style_entries: OxcVec<'a, OutputExpression<'a>> = OxcVec::new_in(allocator);
for style in &metadata.styles {
// Apply CSS scoping for Emulated encapsulation
let style_value = if metadata.encapsulation == ViewEncapsulation::Emulated {
// Use shim_css_text with %COMP% placeholder
// Angular's runtime will replace %COMP% with the actual component ID
let scoped = crate::styles::shim_css_text(style.as_str(), content_attr, host_attr);
// Skip empty styles
if scoped.trim().is_empty() {
continue;
}
Atom::from_in(scoped.as_str(), allocator)
} else {
// For None/ShadowDom, use styles as-is
if style.trim().is_empty() {
continue;
}
style.clone()
};
let style = crate::styles::finalize_component_style(
style.as_str(),
metadata.encapsulation == ViewEncapsulation::Emulated,
content_attr,
host_attr,
options.minify_component_styles,
);
if style.trim().is_empty() {
continue;
}
let style_value = Atom::from_in(style.as_str(), allocator);

style_entries.push(OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::String(style_value), source_span: None },
Expand Down
8 changes: 8 additions & 0 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ pub struct TransformOptions {
///
/// Default: false (metadata is dev-only and usually stripped in production)
pub emit_class_metadata: bool,

/// Minify final component styles before emitting them into `styles: [...]`.
///
/// This runs after Angular style encapsulation, so it applies to the same
/// final CSS strings that are embedded in component definitions.
pub minify_component_styles: bool,
}

/// Input for host metadata when passed via TransformOptions.
Expand Down Expand Up @@ -223,6 +229,7 @@ impl Default for TransformOptions {
resolved_imports: None,
// Class metadata for TestBed support (disabled by default)
emit_class_metadata: false,
minify_component_styles: false,
}
}
}
Expand Down Expand Up @@ -2453,6 +2460,7 @@ fn compile_component_full<'a>(
let definitions = generate_component_definitions(
allocator,
metadata,
options,
&mut job,
compiled.template_fn,
host_binding_result,
Expand Down
68 changes: 68 additions & 0 deletions crates/oxc_angular_compiler/src/styles/minify.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet};

const COMPONENT_PLACEHOLDER: &str = "%COMP%";
const MINIFY_PLACEHOLDER: &str = "OXCANGULARCOMPONENT";

/// Apply Angular style encapsulation and optionally minify the final CSS.
pub fn finalize_component_style(
style: &str,
encapsulate: bool,
content_attr: &str,
host_attr: &str,
minify: bool,
) -> String {
let style = if encapsulate {
super::shim_css_text(style, content_attr, host_attr)
} else {
style.to_string()
};

if !minify || style.trim().is_empty() {
return style;
}

minify_component_style(&style).unwrap_or(style)
}

/// Minify a final component CSS string while preserving Angular's `%COMP%` placeholder.
pub fn minify_component_style(style: &str) -> Option<String> {
let css = style.replace(COMPONENT_PLACEHOLDER, MINIFY_PLACEHOLDER);
let mut stylesheet = StyleSheet::parse(&css, ParserOptions::default()).ok()?;
stylesheet.minify(MinifyOptions::default()).ok()?;

let code =
stylesheet.to_css(PrinterOptions { minify: true, ..PrinterOptions::default() }).ok()?.code;

Some(code.replace(MINIFY_PLACEHOLDER, COMPONENT_PLACEHOLDER))
}

#[cfg(test)]
mod tests {
use super::{finalize_component_style, minify_component_style};

#[test]
fn minifies_css_with_component_placeholders() {
let minified = minify_component_style(
"[_ngcontent-%COMP%] {\n color: red;\n background: transparent;\n}\n",
)
.expect("style should minify");

assert_eq!(minified, "[_ngcontent-%COMP%]{color:red;background:0 0}");
}

#[test]
fn finalizes_emulated_styles_before_minifying() {
let finalized = finalize_component_style(
":host {\n display: block;\n}\n.button {\n color: red;\n}\n",
true,
"_ngcontent-%COMP%",
"_nghost-%COMP%",
true,
);

assert_eq!(
finalized,
"[_nghost-%COMP%]{display:block}.button[_ngcontent-%COMP%]{color:red}"
);
}
}
2 changes: 2 additions & 0 deletions crates/oxc_angular_compiler/src/styles/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@
//! - CSS transformation for component-scoped styles

mod encapsulation;
mod minify;

pub use encapsulation::{encapsulate_style, shim_css_text};
pub use minify::{finalize_component_style, minify_component_style};
28 changes: 28 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,34 @@ export class MultiStyledComponent {}
insta::assert_snapshot!("component_with_multiple_styles", result.code);
}

#[test]
fn test_component_with_minified_styles() {
let allocator = Allocator::default();
let source = r#"
import { Component } from '@angular/core';

@Component({
selector: 'app-styled',
template: '<div class="container">Hello</div>',
styles: ['.container { color: red; background: transparent; }']
})
export class StyledComponent {}
"#;

let mut options = ComponentTransformOptions::default();
options.minify_component_styles = true;

let result = transform_angular_file(&allocator, "styled.component.ts", source, &options, None);

assert_eq!(result.component_count, 1);
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
assert!(
result.code.contains(".container[_ngcontent-%COMP%]{color:red;background:0 0}"),
"Generated code should contain minified component styles: {}",
result.code
);
}

#[test]
fn test_component_without_styles_downgrades_encapsulation() {
let allocator = Allocator::default();
Expand Down
12 changes: 12 additions & 0 deletions napi/angular-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ interface TransformOptions {
// i18n
i18nUseExternalIds?: boolean

// Final component style output
minifyComponentStyles?: boolean

// Component metadata
selector?: string
standalone?: boolean
Expand Down Expand Up @@ -176,6 +179,7 @@ interface AngularPluginOptions {

// Style processing
inlineStylesExtension?: string
minifyComponentStyles?: boolean | 'auto'

// File replacements
fileReplacements?: Array<{
Expand All @@ -185,6 +189,14 @@ interface AngularPluginOptions {
}
```

`minifyComponentStyles` resolves like this:

- `true`: always minify component styles
- `false`: never minify component styles
- `"auto"` or `undefined`: follow the resolved Vite minification settings

For `"auto"`, the plugin uses `build.cssMinify` when it is set, otherwise it falls back to `build.minify`. In dev, `"auto"` defaults to `false`.

## Vite Plugin Architecture

The Vite plugin consists of three sub-plugins:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { execSync } from 'node:child_process'
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

import { test, expect } from '@playwright/test'

const __dirname = fileURLToPath(new URL('.', import.meta.url))
const APP_DIR = join(__dirname, '../app')
const BUILD_OUT_DIR = join(APP_DIR, 'dist-minify')
const TEMP_CONFIG = join(APP_DIR, 'vite.config.minify.ts')

function writeBuildConfig(minify: boolean): void {
writeFileSync(
TEMP_CONFIG,
`
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { angular } from '@oxc-angular/vite';
import { defineConfig } from 'vite';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const tsconfig = path.resolve(__dirname, './tsconfig.json');

export default defineConfig({
plugins: [
angular({
tsconfig,
liveReload: false,
minifyComponentStyles: 'auto',
}),
],
build: {
minify: ${minify},
outDir: 'dist-minify',
rollupOptions: {
external: [/^@angular\\/.+$/, /^rxjs(?:\\/.+)?$/, /^tslib$/],
},
},
});
`.trim(),
'utf-8',
)
}

function cleanup(): void {
rmSync(TEMP_CONFIG, { force: true })
rmSync(BUILD_OUT_DIR, { recursive: true, force: true })
}

function readBuiltJs(): string {
const assetDir = join(BUILD_OUT_DIR, 'assets')
const files = existsSync(assetDir) ? readdirSync(assetDir) : []
const jsFiles = files.filter((file) => file.endsWith('.js'))

expect(jsFiles.length).toBeGreaterThan(0)

return jsFiles.map((file) => readFileSync(join(assetDir, file), 'utf-8')).join('\n')
}

test.describe('build auto minify component styles', () => {
test.afterEach(() => {
cleanup()
})

test('minifies embedded component styles when build.minify is true', () => {
writeBuildConfig(true)

execSync('npx vite build --config vite.config.minify.ts', {
cwd: APP_DIR,
stdio: 'pipe',
timeout: 60000,
})

const output = readBuiltJs()

expect(output).toContain('.card-title[_ngcontent-%COMP%]{color:green;margin:0}')
})

test('keeps embedded component styles unminified when build.minify is false', () => {
writeBuildConfig(false)

execSync('npx vite build --config vite.config.minify.ts', {
cwd: APP_DIR,
stdio: 'pipe',
timeout: 60000,
})

const output = readBuiltJs()

expect(output).toContain(
'.card-title[_ngcontent-%COMP%] {\\n color: green;\\n margin: 0;\\n}',
)
})
})
7 changes: 7 additions & 0 deletions napi/angular-compiler/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,13 @@ export interface TransformOptions {
* Default: false (metadata is dev-only and usually stripped in production)
*/
emitClassMetadata?: boolean
/**
* Minify final component styles before emitting them into `styles: [...]`.
*
* This runs after Angular style encapsulation, so it applies to the same
* final CSS strings that are embedded in generated component definitions.
*/
minifyComponentStyles?: boolean
/**
* Resolved import paths for host directives and other imports.
*
Expand Down
Loading
Loading