Skip to content
Open
Show file tree
Hide file tree
Changes from 64 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
9714ef6
Scaffold `atlaspack_packager_css` and extend BundleGraph API
OscarCookeAbbott Mar 12, 2026
03b76f6
General cleanup
OscarCookeAbbott Mar 12, 2026
d2fe9e8
Aesthetic cleanup
OscarCookeAbbott Mar 12, 2026
7994777
Add changeset
OscarCookeAbbott Mar 12, 2026
0d90d39
Remove unnecessary comments
OscarCookeAbbott Mar 12, 2026
2d435ce
Add mockall dependency and implement CSS packaging logic in CssPackager
OscarCookeAbbott Mar 13, 2026
dbe5cf1
Refactor to use LightningCSS bundler
OscarCookeAbbott Mar 13, 2026
66c9719
- Update changeset
OscarCookeAbbott Mar 13, 2026
c7d9e1b
Replace `unimplemented!` macros with documented stubs to fix unrelate…
OscarCookeAbbott Mar 16, 2026
0eb1f4b
Merge branch 'oscar/native-css-packaging-scaffold' into oscar/native-…
OscarCookeAbbott Mar 16, 2026
efbe8b3
Fix Bundle ID collision and implement FileType::Css dispatch
OscarCookeAbbott Mar 16, 2026
960cc5b
Add changeset
OscarCookeAbbott Mar 16, 2026
ba8a388
Merge branch 'main' into oscar/native-css-packaging-core
OscarCookeAbbott Mar 16, 2026
8547f50
Add URL replacement functionality for CSS assets in the packager
OscarCookeAbbott Mar 16, 2026
87fd3db
Add MIME type support for additional font formats and inline SVG assets
OscarCookeAbbott Mar 16, 2026
35b3cfc
Add changeset
OscarCookeAbbott Mar 16, 2026
d58b675
Implement CSS Module tree-shaking and add used symbols tracking
OscarCookeAbbott Mar 17, 2026
d8497e9
Enhance documentation for CSS rule removal process and add test for n…
OscarCookeAbbott Mar 17, 2026
a1647fe
Move to AST implementation
OscarCookeAbbott Mar 17, 2026
6bfdb5c
- Introduced a `warnings` field in `PackageResult` to capture non-fat…
OscarCookeAbbott Mar 17, 2026
6b374b0
Add changeset
OscarCookeAbbott Mar 17, 2026
a7e1390
Refine asset identification and add support for `composes` declarations
OscarCookeAbbott Mar 17, 2026
c9a11a7
refactor: streamline URL replacement logic in CSS packager
OscarCookeAbbott Mar 17, 2026
9af6122
Remove extra header
OscarCookeAbbott Mar 17, 2026
67d8344
Add source maps to native CSS packaging
OscarCookeAbbott Mar 18, 2026
a16f962
Merge branch 'main' into oscar/native-css-packaging-urls
OscarCookeAbbott Mar 18, 2026
1e57337
Remove errant changeset remnant
OscarCookeAbbott Mar 18, 2026
bd7abdb
Replace bespoke implementation with `pathdiff` as used elsewhere
OscarCookeAbbott Mar 18, 2026
1c5e8ec
Merge branch 'oscar/native-css-packaging-urls' into oscar/native-css-…
OscarCookeAbbott Mar 18, 2026
b78ff12
Merge branch 'main' into oscar/native-css-packaging-tree-shaking
OscarCookeAbbott Mar 19, 2026
7ba6930
Merge branch 'oscar/native-css-packaging-tree-shaking' into oscar/nat…
OscarCookeAbbott Mar 19, 2026
aefc569
Add changeset
OscarCookeAbbott Mar 19, 2026
8ddc022
Restore unrelated comment removal
OscarCookeAbbott Mar 19, 2026
d816892
Add proper import
OscarCookeAbbott Mar 19, 2026
5a91f91
Merge branch 'oscar/native-css-packaging-tree-shaking' into oscar/nat…
OscarCookeAbbott Mar 19, 2026
53f0b56
Initial pass at full integration, parity tests
OscarCookeAbbott Mar 20, 2026
4cb16a6
Implement inline style attribute handling and CSS variable substituti…
OscarCookeAbbott Mar 20, 2026
a00ffe6
Big refactoring and cleanup pass
OscarCookeAbbott Mar 20, 2026
f9942f1
Cleanup
OscarCookeAbbott Mar 20, 2026
86d07dc
Enhance tests: Update assertions in packager-parity to check for addi…
OscarCookeAbbott Mar 20, 2026
ada12b7
Parity testing improvements and cleaning
OscarCookeAbbott Mar 20, 2026
00e8ff1
Make parity tests actually compare properly
OscarCookeAbbott Mar 24, 2026
5992ac4
Fix minification and related cosmetic discrepancies
OscarCookeAbbott Mar 25, 2026
6d455b5
Remove outdated defensive native file finding from parity tests
OscarCookeAbbott Mar 25, 2026
cb5cd90
Lots more cleanup of the core packager
OscarCookeAbbott Mar 26, 2026
4ba018b
Update LightningCSS versions
OscarCookeAbbott Mar 26, 2026
ac6ac3a
Enhance CSS packager with percent-encoding support and improve test c…
OscarCookeAbbott Mar 26, 2026
69dc2de
Possibly undesired fix for hashed filename length mismatch vs JS impl…
OscarCookeAbbott Mar 26, 2026
d55a6cf
Possibly undesired test workaround for unimplemented SVG optimisation…
OscarCookeAbbott Mar 26, 2026
e508213
Possibly overkill solutions to unknown files in full native, bundle n…
OscarCookeAbbott Mar 27, 2026
bd580d5
Add and clean tests
OscarCookeAbbott Mar 27, 2026
5aa8205
Fix API change oversight
OscarCookeAbbott Mar 27, 2026
e3443a3
Merge branch 'main' into oscar/native-css-packaging-integration
OscarCookeAbbott Mar 27, 2026
93fde24
Fix incorrect `main` merge
OscarCookeAbbott Mar 30, 2026
0720ae3
More errant cleanup
OscarCookeAbbott Mar 30, 2026
b6413fc
Fix oversight
OscarCookeAbbott Mar 30, 2026
1e07586
Add more tests
OscarCookeAbbott Mar 30, 2026
a94fa3c
- Fix hardcoded browser targets
OscarCookeAbbott Mar 30, 2026
23cd384
Pass at improving more test code quality
OscarCookeAbbott Mar 30, 2026
833fb74
Test quality pass
OscarCookeAbbott Mar 30, 2026
932c1ad
- Use hashmap instead of repeated relative search
OscarCookeAbbott Mar 31, 2026
92f53f3
Clean nested block
OscarCookeAbbott Mar 31, 2026
0e82e88
Add changeset
OscarCookeAbbott Mar 31, 2026
aa991dd
Merge branch 'main' into oscar/native-css-packaging-integration
OscarCookeAbbott Mar 31, 2026
2c0bf28
Fix failing un-updated tests
OscarCookeAbbott Mar 31, 2026
99e156c
Merge branch 'main' into oscar/native-css-packaging-integration
OscarCookeAbbott Apr 2, 2026
872b6c4
Potential fix for potential integration test config leak
OscarCookeAbbott Apr 2, 2026
49bed0c
Try fix CI failures by adding explicit `.browserslistrc` to fixtures
OscarCookeAbbott Apr 7, 2026
ff6b991
Merge branch 'main' into oscar/native-css-packaging-integration
OscarCookeAbbott Apr 9, 2026
20ce8dc
- Remove browser target inference, not present in JS version
OscarCookeAbbott Apr 10, 2026
68182cc
Remove vendor-prefixed properties in tests due to irrelevance to pack…
OscarCookeAbbott Apr 10, 2026
5ae3083
Merge branch 'main' into oscar/native-css-packaging-integration
OscarCookeAbbott Apr 10, 2026
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
11 changes: 11 additions & 0 deletions .changeset/eight-phones-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@atlaspack/integration-tests': minor
'@atlaspack/transformer-css': minor
'@atlaspack/optimizer-css': minor
'@atlaspack/packager-css': minor
'@atlaspack/core': minor
'@atlaspack/rust': minor
---

Integrate complete native CSS packager
Add optional raw passthrough for some assets that do not have transformers
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ json = "0.12.4"
json5 = "0.4.1"
lazy_static = "1.5.0"
libc = "0.2.169"
lightningcss = "1.0.0-alpha.59"
lightningcss = "1.0.0-alpha.71"
log = "0.4.22"
lz4_flex = "0.11.3"
mimalloc = { version = "0.1.43", default-features = false }
Expand Down
12 changes: 11 additions & 1 deletion crates/atlaspack/src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ pub mod plugin_cache;
pub trait Plugins {
fn named_pipelines(&self) -> Vec<String>;
fn resolvers(&self) -> Result<Vec<Arc<dyn ResolverPlugin>>, anyhow::Error>;
async fn transformers(&self, asset: &Asset) -> Result<TransformerPipeline, anyhow::Error>;
/// Returns the transformer pipeline for `asset`.
///
/// `allow_empty` mirrors the JS `allowEmpty` / `isURL` flag: when `true` and
/// no transformers match the asset path, an empty (pass-through) pipeline is
/// returned instead of an error. This is used for URL-type assets (e.g. fonts
/// referenced from CSS `url()`) that have no dedicated native transformer.
async fn transformers(
&self,
asset: &Asset,
allow_empty: bool,
) -> Result<TransformerPipeline, anyhow::Error>;
}

pub struct TransformerPipeline {
Expand Down
24 changes: 22 additions & 2 deletions crates/atlaspack/src/plugins/config_plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ impl Plugins for ConfigPlugins {
}

/// Resolve and load transformer plugins for a given path.
async fn transformers(&self, asset: &Asset) -> Result<TransformerPipeline, anyhow::Error> {
async fn transformers(
&self,
asset: &Asset,
allow_empty: bool,
) -> Result<TransformerPipeline, anyhow::Error> {
let mut transformers: Vec<Arc<dyn TransformerPlugin>> = Vec::new();
let named_pattern = asset.pipeline.as_ref().map(|pipeline| NamedPattern {
pipeline,
Expand Down Expand Up @@ -180,6 +184,22 @@ impl Plugins for ConfigPlugins {
}

if transformers.is_empty() {
if allow_empty {
// No transformer matched, but the caller permits a fallback (e.g. a
// URL-type asset like a font or binary file referenced from CSS). Use the
// raw transformer as a pass-through, mirroring the JS `url:*` →
// `@atlaspack/transformer-raw` config entry and `allowEmpty` / `isURL`
// behaviour in `getTransformers`.
let raw =
self
.plugin_cache
.get_or_init_transformer("@atlaspack/transformer-raw", async || {
Ok(Arc::new(AtlaspackRawTransformerPlugin::new(&self.ctx))
as Arc<dyn TransformerPlugin>)
})
.await?;
return Ok(TransformerPipeline::new(vec![raw]));
}
return match asset.pipeline {
None => Err(self.missing_plugin(&asset.file_path, "transformers")),
Some(ref pipeline) => {
Expand Down Expand Up @@ -227,7 +247,7 @@ mod tests {
.unwrap();

let pipeline = config_plugins(ctx)
.transformers(&asset)
.transformers(&asset, false)
.await
.expect("Not to panic");

Expand Down
6 changes: 5 additions & 1 deletion crates/atlaspack/src/requests/asset_graph_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use atlaspack_core::asset_graph::{
AssetGraph, AssetGraphNode, DependencyState, FinalizedSymbolTracker, SymbolTracker,
propagate_requested_symbols,
};
use atlaspack_core::types::{AssetWithDependencies, Dependency, DependencyId};
use atlaspack_core::types::{AssetWithDependencies, Dependency, DependencyId, SpecifierType};

use super::RequestResult;
use super::asset_request::{AssetRequest, AssetRequestOutput};
Expand Down Expand Up @@ -119,6 +119,9 @@ impl AssetGraphRequest {
pipeline: asset.pipeline.clone(),
query: asset.query.clone(),
side_effects: asset.side_effects,
// Incremental rebuilds re-use the previously computed asset; conservatively
// set is_url = false since we don't have the originating dependency here.
is_url: false,
};

outputs.insert(asset_request.id(), asset_request_output);
Expand Down Expand Up @@ -408,6 +411,7 @@ impl AssetGraphBuilder {
pipeline: pipeline.clone(),
query: query.clone(),
side_effects: *side_effects,
is_url: dependency.specifier_type == SpecifierType::Url,
}
}
PathRequestOutput::Excluded => {
Expand Down
67 changes: 45 additions & 22 deletions crates/atlaspack/src/requests/asset_request/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub struct AssetRequest {
pub pipeline: Option<String>,
pub query: Option<String>,
pub side_effects: bool,
/// Whether this asset was reached via a URL dependency (`specifierType: 'url'`).
/// When true and no transformer matches, the asset is passed through unchanged
/// (matching the JS `allowEmpty` / `isURL` behaviour in `getTransformers`).
pub is_url: bool,
}

#[derive(Debug, Clone, PartialEq)]
Expand Down Expand Up @@ -105,7 +109,7 @@ impl Request for AssetRequest {
}
}

let mut result = run_pipelines(asset, request_context).await?;
let mut result = run_pipelines(asset, self.is_url, request_context).await?;

// TODO: Commit the asset with a project path now, as transformers rely on an absolute path
// result.asset.file_path = to_project_path(&self.project_root, &result.asset.file_path);
Expand Down Expand Up @@ -143,16 +147,21 @@ impl Request for AssetRequest {
#[tracing::instrument(level = "trace", skip_all)]
async fn run_pipelines(
input: Asset,
is_url: bool,
request_context: RunRequestContext,
) -> anyhow::Result<TransformResult> {
let plugins = request_context.plugins();
let mut all_invalidations = vec![];
// Each queue entry carries (asset_with_deps, resolved_pipeline, allow_empty).
// `allow_empty` is `true` only for the initial URL asset so that missing
// transformers produce a no-op pass-through instead of an error.
let mut asset_queue = VecDeque::from([(
AssetWithDependencies {
asset: input,
dependencies: Vec::new(),
},
None,
is_url,
)]);
let mut initial_asset: Option<Asset> = None;
let mut initial_asset_dependencies = None;
Expand All @@ -164,6 +173,7 @@ async fn run_pipelines(
dependencies: prev_dependencies,
},
pipeline,
allow_empty,
)) = asset_queue.pop_front()
{
let pipeline = if let Some(pipeline_info) = pipeline {
Expand All @@ -172,7 +182,7 @@ async fn run_pipelines(
let mut file_path = asset_to_modify.file_path.clone();
file_path.set_extension(asset_to_modify.file_type.extension());

plugins.transformers(&asset_to_modify).await?
plugins.transformers(&asset_to_modify, allow_empty).await?
};

let RunPipelineOutput {
Expand All @@ -197,7 +207,7 @@ async fn run_pipelines(
asset_queue.extend(
discovered_assets
.into_iter()
.map(|discovered_asset| (discovered_asset, None)),
.map(|discovered_asset| (discovered_asset, None, false)),
);

match result {
Expand All @@ -216,7 +226,9 @@ async fn run_pipelines(
};
}
PipelineResult::TypeChange(asset, mut dependencies) => {
let next_pipeline = plugins.transformers(&asset).await?;
// Type-change follow-up pipeline always uses allow_empty=false:
// if a type-changed asset has no transformer, that's an error.
let next_pipeline = plugins.transformers(&asset, false).await?;
dependencies.extend(prev_dependencies);

asset_queue.push_front((
Expand All @@ -225,6 +237,7 @@ async fn run_pipelines(
dependencies,
},
Some(next_pipeline),
false,
));
}
}
Expand Down Expand Up @@ -274,7 +287,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
async fn test_run_pipelines_works() {
let mut plugins = MockPlugins::new();
plugins.expect_transformers().returning(move |_| {
plugins.expect_transformers().returning(move |_, _| {
Ok(TransformerPipeline::new(vec![
make_transformer(MockTrasformerOptions {
label: "transformer1",
Expand All @@ -289,7 +302,7 @@ mod tests {

let asset = Asset::default();
let request_context = RunRequestContext::new_for_testing(Arc::new(plugins));
let result = run_pipelines(asset, request_context).await.unwrap();
let result = run_pipelines(asset, false, request_context).await.unwrap();

assert_code(&result.asset, "::transformer1::transformer2");
}
Expand All @@ -299,8 +312,10 @@ mod tests {
let mut plugins = MockPlugins::new();
plugins
.expect_transformers()
.withf(|asset: &Asset| asset.file_path.extension().is_some_and(|ext| ext == "js"))
.returning(move |_| {
.withf(|asset: &Asset, _allow_empty: &bool| {
asset.file_path.extension().is_some_and(|ext| ext == "js")
})
.returning(move |_, _| {
Ok(TransformerPipeline::new(vec![
make_transformer(MockTrasformerOptions {
label: "js-1",
Expand All @@ -317,7 +332,7 @@ mod tests {
let asset = make_asset("index.js", FileType::Js);
let expected_invalidations = vec![PathBuf::from("./tmp")];
let request_context = RunRequestContext::new_for_testing(Arc::new(plugins));
let result = run_pipelines(asset, request_context).await.unwrap();
let result = run_pipelines(asset, false, request_context).await.unwrap();

assert_code(&result.asset, "::js-1::js-2");
assert_eq!(result.invalidate_on_file_change, expected_invalidations);
Expand All @@ -328,8 +343,10 @@ mod tests {
let mut plugins = MockPlugins::new();
plugins
.expect_transformers()
.withf(|asset: &Asset| asset.file_path.extension().is_some_and(|ext| ext == "json"))
.returning(move |_| {
.withf(|asset: &Asset, _allow_empty: &bool| {
asset.file_path.extension().is_some_and(|ext| ext == "json")
})
.returning(move |_, _| {
Ok(TransformerPipeline::new(vec![make_transformer(
MockTrasformerOptions {
label: "json",
Expand All @@ -342,8 +359,8 @@ mod tests {

plugins
.expect_transformers()
.withf(|asset: &Asset| asset.file_type == FileType::Js)
.returning(move |_| {
.withf(|asset: &Asset, _allow_empty: &bool| asset.file_type == FileType::Js)
.returning(move |_, _| {
Ok(TransformerPipeline::new(vec![make_transformer(
MockTrasformerOptions {
label: "js",
Expand All @@ -354,7 +371,7 @@ mod tests {

let asset = make_asset("index.json", FileType::Json);
let request_context = RunRequestContext::new_for_testing(Arc::new(plugins));
let result = run_pipelines(asset, request_context).await.unwrap();
let result = run_pipelines(asset, false, request_context).await.unwrap();

assert_eq!(
result.asset.clone(),
Expand All @@ -372,8 +389,10 @@ mod tests {
let mut plugins = MockPlugins::new();
plugins
.expect_transformers()
.withf(|asset: &Asset| asset.file_path.extension().is_some_and(|ext| ext == "js"))
.returning(move |_| {
.withf(|asset: &Asset, _allow_empty: &bool| {
asset.file_path.extension().is_some_and(|ext| ext == "js")
})
.returning(move |_, _| {
Ok(TransformerPipeline::new(vec![
make_transformer(MockTrasformerOptions {
label: "js-1",
Expand All @@ -390,7 +409,7 @@ mod tests {
let asset = make_asset("index.js", FileType::Js);
let expected_dependencies = vec![Dependency::default()];
let request_context = RunRequestContext::new_for_testing(Arc::new(plugins));
let result = run_pipelines(asset, request_context).await.unwrap();
let result = run_pipelines(asset, false, request_context).await.unwrap();

assert_code(&result.asset, "::js-1::js-2");
assert_eq!(result.dependencies, expected_dependencies);
Expand All @@ -402,8 +421,10 @@ mod tests {

plugins
.expect_transformers()
.withf(|asset: &Asset| asset.file_path.extension().is_some_and(|ext| ext == "js"))
.returning(move |_| {
.withf(|asset: &Asset, _allow_empty: &bool| {
asset.file_path.extension().is_some_and(|ext| ext == "js")
})
.returning(move |_, _| {
Ok(TransformerPipeline::new(vec![
make_transformer(MockTrasformerOptions {
label: "js-1",
Expand All @@ -422,8 +443,10 @@ mod tests {

plugins
.expect_transformers()
.withf(|asset: &Asset| asset.file_path.extension().is_some_and(|ext| ext == "css"))
.returning(move |_| {
.withf(|asset: &Asset, _allow_empty: &bool| {
asset.file_path.extension().is_some_and(|ext| ext == "css")
})
.returning(move |_, _| {
Ok(TransformerPipeline::new(vec![
make_transformer(MockTrasformerOptions {
label: "css-1",
Expand All @@ -439,7 +462,7 @@ mod tests {

let asset = make_asset("index.js", FileType::Js);
let request_context = RunRequestContext::new_for_testing(Arc::new(plugins));
let result = run_pipelines(asset, request_context).await.unwrap();
let result = run_pipelines(asset, false, request_context).await.unwrap();

assert_code(&result.asset, "::js-1::js-2");
assert_eq!(result.discovered_assets.len(), 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ pub async fn run_pipeline(input: RunPipelineInput) -> anyhow::Result<RunPipeline
// When the Asset changes file_type we need to regenerate its id
current_asset.update_id(&project_root);

let next_pipeline = plugins.transformers(&current_asset).await?;
let next_pipeline = plugins.transformers(&current_asset, false).await?;

if pipeline.should_run_new_pipeline(&next_pipeline) {
tracing::debug!(
Expand Down
Loading
Loading