Skip to main content

turbopack_node/transforms/
postcss.rs

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