turbopack_node/transforms/
postcss.rs

1use anyhow::{Context, Result, bail};
2use indoc::formatdoc;
3use serde::{Deserialize, Serialize};
4use turbo_rcstr::{RcStr, rcstr};
5use turbo_tasks::{
6    Completion, Completions, NonLocalValue, ResolvedVc, TaskInput, TryFlatJoinIterExt, Vc,
7    fxindexmap, trace::TraceRawVcs,
8};
9use turbo_tasks_bytes::stream::SingleValue;
10use turbo_tasks_fs::{
11    File, FileContent, FileSystemEntryType, FileSystemPath, json::parse_json_with_source_context,
12};
13use turbopack_core::{
14    asset::{Asset, AssetContent},
15    changed::any_content_changed_of_module,
16    context::{AssetContext, ProcessResult},
17    file_source::FileSource,
18    ident::AssetIdent,
19    issue::IssueDescriptionExt,
20    reference_type::{EntryReferenceSubType, InnerAssets, ReferenceType},
21    resolve::{FindContextFileResult, find_context_file_or_package_key, options::ImportMapping},
22    source::Source,
23    source_map::{GenerateSourceMap, OptionStringifiedSourceMap},
24    source_transform::SourceTransform,
25    virtual_source::VirtualSource,
26};
27use turbopack_ecmascript::runtime_functions::TURBOPACK_EXTERNAL_IMPORT;
28
29use super::{
30    util::{EmittedAsset, emitted_assets_to_virtual_sources},
31    webpack::WebpackLoaderContext,
32};
33use crate::{
34    embed_js::embed_file_path, execution_context::ExecutionContext,
35    transforms::webpack::evaluate_webpack_loader,
36};
37
38#[derive(Debug, Serialize, Deserialize, Clone)]
39#[serde(rename_all = "camelCase")]
40#[turbo_tasks::value(serialization = "custom")]
41struct PostCssProcessingResult {
42    css: String,
43    map: Option<String>,
44    assets: Option<Vec<EmittedAsset>>,
45}
46
47#[derive(
48    Default,
49    Copy,
50    Clone,
51    PartialEq,
52    Eq,
53    Hash,
54    Debug,
55    TraceRawVcs,
56    Serialize,
57    Deserialize,
58    TaskInput,
59    NonLocalValue,
60)]
61pub enum PostCssConfigLocation {
62    #[default]
63    ProjectPath,
64    ProjectPathOrLocalPath,
65}
66
67#[turbo_tasks::value(shared)]
68#[derive(Clone, Default)]
69pub struct PostCssTransformOptions {
70    pub postcss_package: Option<ResolvedVc<ImportMapping>>,
71    pub config_location: PostCssConfigLocation,
72    pub placeholder_for_future_extensions: u8,
73}
74
75#[turbo_tasks::function]
76fn postcss_configs() -> Vc<Vec<RcStr>> {
77    Vc::cell(
78        [
79            ".postcssrc",
80            ".postcssrc.json",
81            ".postcssrc.yaml",
82            ".postcssrc.yml",
83            ".postcssrc.js",
84            ".postcssrc.mjs",
85            ".postcssrc.cjs",
86            ".config/postcssrc",
87            ".config/postcssrc.json",
88            ".config/postcssrc.yaml",
89            ".config/postcssrc.yml",
90            ".config/postcssrc.js",
91            ".config/postcssrc.mjs",
92            ".config/postcssrc.cjs",
93            "postcss.config.js",
94            "postcss.config.mjs",
95            "postcss.config.cjs",
96            "postcss.config.json",
97        ]
98        .into_iter()
99        .map(RcStr::from)
100        .collect(),
101    )
102}
103
104#[turbo_tasks::value]
105pub struct PostCssTransform {
106    evaluate_context: ResolvedVc<Box<dyn AssetContext>>,
107    execution_context: ResolvedVc<ExecutionContext>,
108    config_location: PostCssConfigLocation,
109    source_maps: bool,
110}
111
112#[turbo_tasks::value_impl]
113impl PostCssTransform {
114    #[turbo_tasks::function]
115    pub fn new(
116        evaluate_context: ResolvedVc<Box<dyn AssetContext>>,
117        execution_context: ResolvedVc<ExecutionContext>,
118        config_location: PostCssConfigLocation,
119        source_maps: bool,
120    ) -> Vc<Self> {
121        PostCssTransform {
122            evaluate_context,
123            execution_context,
124            config_location,
125            source_maps,
126        }
127        .cell()
128    }
129}
130
131#[turbo_tasks::value_impl]
132impl SourceTransform for PostCssTransform {
133    #[turbo_tasks::function]
134    fn transform(&self, source: ResolvedVc<Box<dyn Source>>) -> Vc<Box<dyn Source>> {
135        Vc::upcast(
136            PostCssTransformedAsset {
137                evaluate_context: self.evaluate_context,
138                execution_context: self.execution_context,
139                config_location: self.config_location,
140                source,
141                source_map: self.source_maps,
142            }
143            .cell(),
144        )
145    }
146}
147
148#[turbo_tasks::value]
149struct PostCssTransformedAsset {
150    evaluate_context: ResolvedVc<Box<dyn AssetContext>>,
151    execution_context: ResolvedVc<ExecutionContext>,
152    config_location: PostCssConfigLocation,
153    source: ResolvedVc<Box<dyn Source>>,
154    source_map: bool,
155}
156
157#[turbo_tasks::value_impl]
158impl Source for PostCssTransformedAsset {
159    #[turbo_tasks::function]
160    fn ident(&self) -> Vc<AssetIdent> {
161        self.source.ident()
162    }
163}
164
165#[turbo_tasks::value_impl]
166impl Asset for PostCssTransformedAsset {
167    #[turbo_tasks::function]
168    async fn content(self: ResolvedVc<Self>) -> Result<Vc<AssetContent>> {
169        let this = self.await?;
170        Ok(*transform_process_operation(self)
171            .issue_file_path(
172                this.source.ident().path().await?.clone_value(),
173                "PostCSS processing",
174            )
175            .await?
176            .connect()
177            .await?
178            .content)
179    }
180}
181
182#[turbo_tasks::function(operation)]
183fn transform_process_operation(
184    asset: ResolvedVc<PostCssTransformedAsset>,
185) -> Vc<ProcessPostCssResult> {
186    asset.process()
187}
188
189#[turbo_tasks::value]
190struct ProcessPostCssResult {
191    content: ResolvedVc<AssetContent>,
192    assets: Vec<ResolvedVc<VirtualSource>>,
193}
194
195#[turbo_tasks::function]
196async fn config_changed(
197    asset_context: Vc<Box<dyn AssetContext>>,
198    postcss_config_path: FileSystemPath,
199) -> Result<Vc<Completion>> {
200    let config_asset = asset_context
201        .process(
202            Vc::upcast(FileSource::new(postcss_config_path.clone())),
203            ReferenceType::Internal(InnerAssets::empty().to_resolved().await?),
204        )
205        .module();
206
207    Ok(Vc::<Completions>::cell(vec![
208        any_content_changed_of_module(config_asset)
209            .to_resolved()
210            .await?,
211        extra_configs_changed(asset_context, postcss_config_path)
212            .to_resolved()
213            .await?,
214    ])
215    .completed())
216}
217
218#[turbo_tasks::function]
219async fn extra_configs_changed(
220    asset_context: Vc<Box<dyn AssetContext>>,
221    postcss_config_path: FileSystemPath,
222) -> Result<Vc<Completion>> {
223    let parent_path = postcss_config_path.parent();
224
225    let config_paths = [
226        parent_path.join("tailwind.config.js")?,
227        parent_path.join("tailwind.config.mjs")?,
228        parent_path.join("tailwind.config.ts")?,
229    ];
230
231    let configs = config_paths
232        .into_iter()
233        .map(|path| async move {
234            Ok(
235                if matches!(&*path.get_type().await?, FileSystemEntryType::File) {
236                    match *asset_context
237                        .process(
238                            Vc::upcast(FileSource::new(path)),
239                            ReferenceType::Internal(InnerAssets::empty().to_resolved().await?),
240                        )
241                        .try_into_module()
242                        .await?
243                    {
244                        Some(module) => {
245                            Some(any_content_changed_of_module(*module).to_resolved().await?)
246                        }
247                        None => None,
248                    }
249                } else {
250                    None
251                },
252            )
253        })
254        .try_flat_join()
255        .await?;
256
257    Ok(Vc::<Completions>::cell(configs).completed())
258}
259
260#[turbo_tasks::value]
261pub struct JsonSource {
262    pub path: FileSystemPath,
263    pub key: ResolvedVc<Option<RcStr>>,
264    pub allow_json5: bool,
265}
266
267#[turbo_tasks::value_impl]
268impl JsonSource {
269    #[turbo_tasks::function]
270    pub fn new(
271        path: FileSystemPath,
272        key: ResolvedVc<Option<RcStr>>,
273        allow_json5: bool,
274    ) -> Vc<Self> {
275        JsonSource {
276            path,
277            key,
278            allow_json5,
279        }
280        .cell()
281    }
282}
283
284#[turbo_tasks::value_impl]
285impl Source for JsonSource {
286    #[turbo_tasks::function]
287    async fn ident(&self) -> Result<Vc<AssetIdent>> {
288        match &*self.key.await? {
289            Some(key) => Ok(AssetIdent::from_path(
290                self.path.append(".")?.append(key)?.append(".json")?,
291            )),
292            None => Ok(AssetIdent::from_path(self.path.append(".json")?)),
293        }
294    }
295}
296
297#[turbo_tasks::value_impl]
298impl Asset for JsonSource {
299    #[turbo_tasks::function]
300    async fn content(&self) -> Result<Vc<AssetContent>> {
301        let file_type = &*self.path.get_type().await?;
302        match file_type {
303            FileSystemEntryType::File => {
304                let json = if self.allow_json5 {
305                    self.path.read_json5().content().await?
306                } else {
307                    self.path.read_json().content().await?
308                };
309                let value = match &*self.key.await? {
310                    Some(key) => {
311                        let Some(value) = json.get(&**key) else {
312                            anyhow::bail!("Invalid file type {:?}", file_type)
313                        };
314                        value
315                    }
316                    None => &*json,
317                };
318                Ok(AssetContent::file(File::from(value.to_string()).into()))
319            }
320            FileSystemEntryType::NotFound => {
321                Ok(AssetContent::File(FileContent::NotFound.resolved_cell()).cell())
322            }
323            _ => Err(anyhow::anyhow!("Invalid file type {:?}", file_type)),
324        }
325    }
326}
327
328#[turbo_tasks::function]
329pub(crate) async fn config_loader_source(
330    project_path: FileSystemPath,
331    postcss_config_path: FileSystemPath,
332) -> Result<Vc<Box<dyn Source>>> {
333    let postcss_config_path_value = postcss_config_path.clone();
334    let postcss_config_path_filename = postcss_config_path_value.file_name();
335
336    if postcss_config_path_filename == "package.json" {
337        return Ok(Vc::upcast(JsonSource::new(
338            postcss_config_path,
339            Vc::cell(Some(rcstr!("postcss"))),
340            false,
341        )));
342    }
343
344    if postcss_config_path_value.path.ends_with(".json")
345        || postcss_config_path_filename == ".postcssrc"
346    {
347        return Ok(Vc::upcast(JsonSource::new(
348            postcss_config_path,
349            Vc::cell(None),
350            true,
351        )));
352    }
353
354    // We can only load js files with `import()`.
355    if !postcss_config_path_value.path.ends_with(".js") {
356        return Ok(Vc::upcast(FileSource::new(postcss_config_path)));
357    }
358
359    let Some(config_path) = project_path.get_relative_path_to(&postcss_config_path_value) else {
360        bail!("Unable to get relative path to postcss config");
361    };
362
363    // We don't want to bundle the config file, so we load it with `import()`.
364    // Bundling would break the ability to use `require.resolve` in the config file.
365    let code = formatdoc! {
366        r#"
367            import {{ pathToFileURL }} from 'node:url';
368            import path from 'node:path';
369
370            const configPath = path.join(process.cwd(), {config_path});
371            // Absolute paths don't work with ESM imports on Windows:
372            // https://github.com/nodejs/node/issues/31710
373            // convert it to a file:// URL, which works on all platforms
374            const configUrl = pathToFileURL(configPath).toString();
375            const mod = await {TURBOPACK_EXTERNAL_IMPORT}(configUrl);
376
377            export default mod.default ?? mod;
378        "#,
379        config_path = serde_json::to_string(&config_path).expect("a string should be serializable"),
380    };
381
382    Ok(Vc::upcast(VirtualSource::new(
383        postcss_config_path.append("_.loader.mjs")?,
384        AssetContent::file(File::from(code).into()),
385    )))
386}
387
388#[turbo_tasks::function]
389async fn postcss_executor(
390    asset_context: Vc<Box<dyn AssetContext>>,
391    project_path: FileSystemPath,
392    postcss_config_path: FileSystemPath,
393) -> Result<Vc<ProcessResult>> {
394    let config_asset = asset_context
395        .process(
396            config_loader_source(project_path, postcss_config_path),
397            ReferenceType::Entry(EntryReferenceSubType::Undefined),
398        )
399        .module()
400        .to_resolved()
401        .await?;
402
403    Ok(asset_context.process(
404        Vc::upcast(FileSource::new(
405            embed_file_path(rcstr!("transforms/postcss.ts"))
406                .await?
407                .clone_value(),
408        )),
409        ReferenceType::Internal(ResolvedVc::cell(fxindexmap! {
410            rcstr!("CONFIG") => config_asset
411        })),
412    ))
413}
414
415async fn find_config_in_location(
416    project_path: FileSystemPath,
417    location: PostCssConfigLocation,
418    source: Vc<Box<dyn Source>>,
419) -> Result<Option<FileSystemPath>> {
420    if let FindContextFileResult::Found(config_path, _) =
421        &*find_context_file_or_package_key(project_path, postcss_configs(), rcstr!("postcss"))
422            .await?
423    {
424        return Ok(Some(config_path.clone()));
425    }
426
427    if matches!(location, PostCssConfigLocation::ProjectPathOrLocalPath)
428        && let FindContextFileResult::Found(config_path, _) = &*find_context_file_or_package_key(
429            source.ident().path().await?.parent(),
430            postcss_configs(),
431            rcstr!("postcss"),
432        )
433        .await?
434    {
435        return Ok(Some(config_path.clone()));
436    }
437
438    Ok(None)
439}
440
441#[turbo_tasks::value_impl]
442impl GenerateSourceMap for PostCssTransformedAsset {
443    #[turbo_tasks::function]
444    async fn generate_source_map(&self) -> Result<Vc<OptionStringifiedSourceMap>> {
445        let source = Vc::try_resolve_sidecast::<Box<dyn GenerateSourceMap>>(*self.source).await?;
446        match source {
447            Some(source) => Ok(source.generate_source_map()),
448            None => Ok(Vc::cell(None)),
449        }
450    }
451}
452
453#[turbo_tasks::value_impl]
454impl PostCssTransformedAsset {
455    #[turbo_tasks::function]
456    async fn process(&self) -> Result<Vc<ProcessPostCssResult>> {
457        let ExecutionContext {
458            project_path,
459            chunking_context,
460            env,
461        } = &*self.execution_context.await?;
462
463        // For this postcss transform, there is no guarantee that looking up for the
464        // source path will arrives specific project config for the postcss.
465        // i.e, this is possible
466        // - root
467        //  - node_modules
468        //     - somepkg/(some.module.css, postcss.config.js) // this could be symlinked local, or
469        //       actual remote pkg or anything
470        //  - packages // root of workspace pkgs
471        //     - pkg1/(postcss.config.js) // The actual config we're looking for
472        //
473        // We look for the config in the project path first, then the source path
474        let Some(config_path) =
475            find_config_in_location(project_path.clone(), self.config_location, *self.source)
476                .await?
477        else {
478            return Ok(ProcessPostCssResult {
479                content: self.source.content().to_resolved().await?,
480                assets: Vec::new(),
481            }
482            .cell());
483        };
484
485        let source_content = self.source.content();
486        let AssetContent::File(file) = *source_content.await? else {
487            bail!("PostCSS transform only support transforming files");
488        };
489        let FileContent::Content(content) = &*file.await? else {
490            return Ok(ProcessPostCssResult {
491                content: AssetContent::File(FileContent::NotFound.resolved_cell()).resolved_cell(),
492                assets: Vec::new(),
493            }
494            .cell());
495        };
496        let content = content.content().to_str()?;
497        let evaluate_context = self.evaluate_context;
498        let source_map = self.source_map;
499
500        // This invalidates the transform when the config changes.
501        let config_changed = config_changed(*evaluate_context, config_path.clone())
502            .to_resolved()
503            .await?;
504
505        let postcss_executor =
506            postcss_executor(*evaluate_context, project_path.clone(), config_path)
507                .module()
508                .to_resolved()
509                .await?;
510        let css_fs_path = self.source.ident().path();
511
512        // We need to get a path relative to the project because the postcss loader
513        // runs with the project as the current working directory.
514        let css_path =
515            if let Some(css_path) = project_path.get_relative_path_to(&*css_fs_path.await?) {
516                css_path.into_owned()
517            } else {
518                // This shouldn't be an error since it can happen on virtual assets
519                "".into()
520            };
521
522        let config_value = evaluate_webpack_loader(WebpackLoaderContext {
523            module_asset: postcss_executor,
524            cwd: project_path.clone(),
525            env: *env,
526            context_source_for_issue: self.source,
527            asset_context: evaluate_context,
528            chunking_context: *chunking_context,
529            resolve_options_context: None,
530            args: vec![
531                ResolvedVc::cell(content.into()),
532                ResolvedVc::cell(css_path.into()),
533                ResolvedVc::cell(source_map.into()),
534            ],
535            additional_invalidation: config_changed,
536        })
537        .await?;
538
539        let SingleValue::Single(val) = config_value.try_into_single().await? else {
540            // An error happened, which has already been converted into an issue.
541            return Ok(ProcessPostCssResult {
542                content: AssetContent::File(FileContent::NotFound.resolved_cell()).resolved_cell(),
543                assets: Vec::new(),
544            }
545            .cell());
546        };
547        let processed_css: PostCssProcessingResult = parse_json_with_source_context(val.to_str()?)
548            .context("Unable to deserializate response from PostCSS transform operation")?;
549
550        // TODO handle SourceMap
551        let file = File::from(processed_css.css);
552        let assets = emitted_assets_to_virtual_sources(processed_css.assets).await?;
553        let content =
554            AssetContent::File(FileContent::Content(file).resolved_cell()).resolved_cell();
555        Ok(ProcessPostCssResult { content, assets }.cell())
556    }
557}