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