Skip to content

Commit 1cbf69e

Browse files
committed
add native component style minification
1 parent f6297c8 commit 1cbf69e

File tree

15 files changed

+1339
-62
lines changed

15 files changed

+1339
-62
lines changed

Cargo.lock

Lines changed: 759 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oxc_angular_compiler/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ oxc_sourcemap = { workspace = true }
2626
miette = { workspace = true }
2727
rustc-hash = { workspace = true }
2828
indexmap = { workspace = true }
29+
lightningcss = "1.0.0-alpha.71"
2930
oxc_resolver = { version = "11", optional = true }
3031
pathdiff = { version = "0.2", optional = true }
3132
semver = "1.0.27"

crates/oxc_angular_compiler/src/component/definition.rs

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use super::metadata::{
2323
ViewEncapsulation,
2424
};
2525
use super::namespace_registry::NamespaceRegistry;
26+
use super::transform::TransformOptions;
2627
use crate::directive::{
2728
create_host_directive_mappings_array, create_inputs_literal, create_outputs_literal,
2829
};
@@ -62,6 +63,7 @@ pub struct ComponentDefinitions<'a> {
6263
pub fn generate_component_definitions<'a>(
6364
allocator: &'a Allocator,
6465
metadata: &ComponentMetadata<'a>,
66+
options: &TransformOptions,
6567
job: &mut ComponentCompilationJob<'a>,
6668
template_fn: FunctionExpr<'a>,
6769
host_binding_result: Option<HostBindingCompilationResult<'a>>,
@@ -79,6 +81,7 @@ pub fn generate_component_definitions<'a>(
7981
let cmp_definition = generate_cmp_definition(
8082
allocator,
8183
metadata,
84+
options,
8285
job,
8386
template_fn,
8487
host_binding_result,
@@ -109,6 +112,7 @@ pub fn generate_component_definitions<'a>(
109112
fn generate_cmp_definition<'a>(
110113
allocator: &'a Allocator,
111114
metadata: &ComponentMetadata<'a>,
115+
options: &TransformOptions,
112116
job: &mut ComponentCompilationJob<'a>,
113117
template_fn: FunctionExpr<'a>,
114118
host_binding_result: Option<HostBindingCompilationResult<'a>>,
@@ -435,23 +439,17 @@ fn generate_cmp_definition<'a>(
435439
if !metadata.styles.is_empty() {
436440
let mut style_entries: OxcVec<'a, OutputExpression<'a>> = OxcVec::new_in(allocator);
437441
for style in &metadata.styles {
438-
// Apply CSS scoping for Emulated encapsulation
439-
let style_value = if metadata.encapsulation == ViewEncapsulation::Emulated {
440-
// Use shim_css_text with %COMP% placeholder
441-
// Angular's runtime will replace %COMP% with the actual component ID
442-
let scoped = crate::styles::shim_css_text(style.as_str(), content_attr, host_attr);
443-
// Skip empty styles
444-
if scoped.trim().is_empty() {
445-
continue;
446-
}
447-
Atom::from_in(scoped.as_str(), allocator)
448-
} else {
449-
// For None/ShadowDom, use styles as-is
450-
if style.trim().is_empty() {
451-
continue;
452-
}
453-
style.clone()
454-
};
442+
let style = crate::styles::finalize_component_style(
443+
style.as_str(),
444+
metadata.encapsulation == ViewEncapsulation::Emulated,
445+
content_attr,
446+
host_attr,
447+
options.minify_component_styles,
448+
);
449+
if style.trim().is_empty() {
450+
continue;
451+
}
452+
let style_value = Atom::from_in(style.as_str(), allocator);
455453

456454
style_entries.push(OutputExpression::Literal(Box::new_in(
457455
LiteralExpr { value: LiteralValue::String(style_value), source_span: None },

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ pub struct TransformOptions {
174174
///
175175
/// Default: false (metadata is dev-only and usually stripped in production)
176176
pub emit_class_metadata: bool,
177+
178+
/// Minify final component styles before emitting them into `styles: [...]`.
179+
///
180+
/// This runs after Angular style encapsulation, so it applies to the same
181+
/// final CSS strings that are embedded in component definitions.
182+
pub minify_component_styles: bool,
177183
}
178184

179185
/// Input for host metadata when passed via TransformOptions.
@@ -223,6 +229,7 @@ impl Default for TransformOptions {
223229
resolved_imports: None,
224230
// Class metadata for TestBed support (disabled by default)
225231
emit_class_metadata: false,
232+
minify_component_styles: false,
226233
}
227234
}
228235
}
@@ -2453,6 +2460,7 @@ fn compile_component_full<'a>(
24532460
let definitions = generate_component_definitions(
24542461
allocator,
24552462
metadata,
2463+
options,
24562464
&mut job,
24572465
compiled.template_fn,
24582466
host_binding_result,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet};
2+
3+
const COMPONENT_PLACEHOLDER: &str = "%COMP%";
4+
const MINIFY_PLACEHOLDER: &str = "OXCANGULARCOMPONENT";
5+
6+
/// Apply Angular style encapsulation and optionally minify the final CSS.
7+
pub fn finalize_component_style(
8+
style: &str,
9+
encapsulate: bool,
10+
content_attr: &str,
11+
host_attr: &str,
12+
minify: bool,
13+
) -> String {
14+
let style = if encapsulate {
15+
super::shim_css_text(style, content_attr, host_attr)
16+
} else {
17+
style.to_string()
18+
};
19+
20+
if !minify || style.trim().is_empty() {
21+
return style;
22+
}
23+
24+
minify_component_style(&style).unwrap_or(style)
25+
}
26+
27+
/// Minify a final component CSS string while preserving Angular's `%COMP%` placeholder.
28+
pub fn minify_component_style(style: &str) -> Option<String> {
29+
let css = style.replace(COMPONENT_PLACEHOLDER, MINIFY_PLACEHOLDER);
30+
let mut stylesheet = StyleSheet::parse(&css, ParserOptions::default()).ok()?;
31+
stylesheet.minify(MinifyOptions::default()).ok()?;
32+
33+
let code =
34+
stylesheet.to_css(PrinterOptions { minify: true, ..PrinterOptions::default() }).ok()?.code;
35+
36+
Some(code.replace(MINIFY_PLACEHOLDER, COMPONENT_PLACEHOLDER))
37+
}
38+
39+
#[cfg(test)]
40+
mod tests {
41+
use super::{finalize_component_style, minify_component_style};
42+
43+
#[test]
44+
fn minifies_css_with_component_placeholders() {
45+
let minified = minify_component_style(
46+
"[_ngcontent-%COMP%] {\n color: red;\n background: transparent;\n}\n",
47+
)
48+
.expect("style should minify");
49+
50+
assert_eq!(minified, "[_ngcontent-%COMP%]{color:red;background:0 0}");
51+
}
52+
53+
#[test]
54+
fn finalizes_emulated_styles_before_minifying() {
55+
let finalized = finalize_component_style(
56+
":host {\n display: block;\n}\n.button {\n color: red;\n}\n",
57+
true,
58+
"_ngcontent-%COMP%",
59+
"_nghost-%COMP%",
60+
true,
61+
);
62+
63+
assert_eq!(
64+
finalized,
65+
"[_nghost-%COMP%]{display:block}.button[_ngcontent-%COMP%]{color:red}"
66+
);
67+
}
68+
}

crates/oxc_angular_compiler/src/styles/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@
55
//! - CSS transformation for component-scoped styles
66
77
mod encapsulation;
8+
mod minify;
89

910
pub use encapsulation::{encapsulate_style, shim_css_text};
11+
pub use minify::{finalize_component_style, minify_component_style};

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1524,6 +1524,34 @@ export class MultiStyledComponent {}
15241524
insta::assert_snapshot!("component_with_multiple_styles", result.code);
15251525
}
15261526

1527+
#[test]
1528+
fn test_component_with_minified_styles() {
1529+
let allocator = Allocator::default();
1530+
let source = r#"
1531+
import { Component } from '@angular/core';
1532+
1533+
@Component({
1534+
selector: 'app-styled',
1535+
template: '<div class="container">Hello</div>',
1536+
styles: ['.container { color: red; background: transparent; }']
1537+
})
1538+
export class StyledComponent {}
1539+
"#;
1540+
1541+
let mut options = ComponentTransformOptions::default();
1542+
options.minify_component_styles = true;
1543+
1544+
let result = transform_angular_file(&allocator, "styled.component.ts", source, &options, None);
1545+
1546+
assert_eq!(result.component_count, 1);
1547+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
1548+
assert!(
1549+
result.code.contains(".container[_ngcontent-%COMP%]{color:red;background:0 0}"),
1550+
"Generated code should contain minified component styles: {}",
1551+
result.code
1552+
);
1553+
}
1554+
15271555
#[test]
15281556
fn test_component_without_styles_downgrades_encapsulation() {
15291557
let allocator = Allocator::default();

napi/angular-compiler/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ interface TransformOptions {
139139
// i18n
140140
i18nUseExternalIds?: boolean
141141

142+
// Final component style output
143+
minifyComponentStyles?: boolean
144+
142145
// Component metadata
143146
selector?: string
144147
standalone?: boolean
@@ -176,6 +179,7 @@ interface AngularPluginOptions {
176179

177180
// Style processing
178181
inlineStylesExtension?: string
182+
minifyComponentStyles?: boolean | 'auto'
179183

180184
// File replacements
181185
fileReplacements?: Array<{
@@ -185,6 +189,14 @@ interface AngularPluginOptions {
185189
}
186190
```
187191

192+
`minifyComponentStyles` resolves like this:
193+
194+
- `true`: always minify component styles
195+
- `false`: never minify component styles
196+
- `"auto"` or `undefined`: follow the resolved Vite minification settings
197+
198+
For `"auto"`, the plugin uses `build.cssMinify` when it is set, otherwise it falls back to `build.minify`. In dev, `"auto"` defaults to `false`.
199+
188200
## Vite Plugin Architecture
189201

190202
The Vite plugin consists of three sub-plugins:
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { execSync } from 'node:child_process'
2+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
6+
import { test, expect } from '@playwright/test'
7+
8+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
9+
const APP_DIR = join(__dirname, '../app')
10+
const BUILD_OUT_DIR = join(APP_DIR, 'dist-minify')
11+
const TEMP_CONFIG = join(APP_DIR, 'vite.config.minify.ts')
12+
13+
function writeBuildConfig(minify: boolean): void {
14+
writeFileSync(
15+
TEMP_CONFIG,
16+
`
17+
import path from 'node:path';
18+
import { fileURLToPath } from 'node:url';
19+
20+
import { angular } from '@oxc-angular/vite';
21+
import { defineConfig } from 'vite';
22+
23+
const __filename = fileURLToPath(import.meta.url);
24+
const __dirname = path.dirname(__filename);
25+
const tsconfig = path.resolve(__dirname, './tsconfig.json');
26+
27+
export default defineConfig({
28+
plugins: [
29+
angular({
30+
tsconfig,
31+
liveReload: false,
32+
minifyComponentStyles: 'auto',
33+
}),
34+
],
35+
build: {
36+
minify: ${minify},
37+
outDir: 'dist-minify',
38+
rollupOptions: {
39+
external: [/^@angular\\/.+$/, /^rxjs(?:\\/.+)?$/, /^tslib$/],
40+
},
41+
},
42+
});
43+
`.trim(),
44+
'utf-8',
45+
)
46+
}
47+
48+
function cleanup(): void {
49+
rmSync(TEMP_CONFIG, { force: true })
50+
rmSync(BUILD_OUT_DIR, { recursive: true, force: true })
51+
}
52+
53+
function readBuiltJs(): string {
54+
const assetDir = join(BUILD_OUT_DIR, 'assets')
55+
const files = existsSync(assetDir) ? readdirSync(assetDir) : []
56+
const jsFiles = files.filter((file) => file.endsWith('.js'))
57+
58+
expect(jsFiles.length).toBeGreaterThan(0)
59+
60+
return jsFiles.map((file) => readFileSync(join(assetDir, file), 'utf-8')).join('\n')
61+
}
62+
63+
test.describe('build auto minify component styles', () => {
64+
test.afterEach(() => {
65+
cleanup()
66+
})
67+
68+
test('minifies embedded component styles when build.minify is true', () => {
69+
writeBuildConfig(true)
70+
71+
execSync('npx vite build --config vite.config.minify.ts', {
72+
cwd: APP_DIR,
73+
stdio: 'pipe',
74+
timeout: 60000,
75+
})
76+
77+
const output = readBuiltJs()
78+
79+
expect(output).toContain('.card-title[_ngcontent-%COMP%]{color:green;margin:0}')
80+
})
81+
82+
test('keeps embedded component styles unminified when build.minify is false', () => {
83+
writeBuildConfig(false)
84+
85+
execSync('npx vite build --config vite.config.minify.ts', {
86+
cwd: APP_DIR,
87+
stdio: 'pipe',
88+
timeout: 60000,
89+
})
90+
91+
const output = readBuiltJs()
92+
93+
expect(output).toContain(
94+
'.card-title[_ngcontent-%COMP%] {\\n color: green;\\n margin: 0;\\n}',
95+
)
96+
})
97+
})

napi/angular-compiler/index.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,13 @@ export interface TransformOptions {
826826
* Default: false (metadata is dev-only and usually stripped in production)
827827
*/
828828
emitClassMetadata?: boolean
829+
/**
830+
* Minify final component styles before emitting them into `styles: [...]`.
831+
*
832+
* This runs after Angular style encapsulation, so it applies to the same
833+
* final CSS strings that are embedded in generated component definitions.
834+
*/
835+
minifyComponentStyles?: boolean
829836
/**
830837
* Resolved import paths for host directives and other imports.
831838
*

0 commit comments

Comments
 (0)