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, turbofmt,
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, 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(async |path| {
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_filename = postcss_config_path.file_name();
346
347    if postcss_config_path_filename == "package.json" {
348        return Ok(Vc::upcast(JsonSource::new(
349            postcss_config_path,
350            Vc::cell(Some(rcstr!("postcss"))),
351            false,
352        )));
353    }
354
355    if postcss_config_path.path.ends_with(".json") || postcss_config_path_filename == ".postcssrc" {
356        return Ok(Vc::upcast(JsonSource::new(
357            postcss_config_path,
358            Vc::cell(None),
359            true,
360        )));
361    }
362
363    // We can only load js files with `import()`.
364    if !postcss_config_path.path.ends_with(".js") {
365        return Ok(Vc::upcast(FileSource::new(postcss_config_path)));
366    }
367
368    let Some(config_path) = project_path.get_relative_path_to(&postcss_config_path) else {
369        bail!("Unable to get relative path to postcss config");
370    };
371
372    // We don't want to bundle the config file, so we load it with `import()`.
373    // Bundling would break the ability to use `require.resolve` in the config file.
374    let code = formatdoc! {
375        r#"
376            import {{ pathToFileURL }} from 'node:url';
377            import path from 'node:path';
378
379            const configPath = path.join(process.cwd(), {config_path});
380            // Absolute paths don't work with ESM imports on Windows:
381            // https://github.com/nodejs/node/issues/31710
382            // convert it to a file:// URL, which works on all platforms
383            const configUrl = pathToFileURL(configPath).toString();
384            const mod = await {TURBOPACK_EXTERNAL_IMPORT}(configUrl);
385
386            export default mod.default ?? mod;
387        "#,
388        config_path = serde_json::to_string(&config_path).expect("a string should be serializable"),
389    };
390
391    Ok(Vc::upcast(VirtualSource::new(
392        postcss_config_path.append("_.loader.mjs")?,
393        AssetContent::file(FileContent::Content(File::from(code)).cell()),
394    )))
395}
396
397#[turbo_tasks::function]
398async fn postcss_executor(
399    asset_context: Vc<Box<dyn AssetContext>>,
400    project_path: FileSystemPath,
401    postcss_config_path: FileSystemPath,
402) -> Result<Vc<ProcessResult>> {
403    let config_asset = asset_context
404        .process(
405            config_loader_source(project_path, postcss_config_path.clone()),
406            ReferenceType::Entry(EntryReferenceSubType::Undefined),
407        )
408        .module()
409        .to_resolved()
410        .await?;
411
412    Ok(asset_context.process(
413        Vc::upcast(FileSource::new_with_query(
414            embed_file_path(rcstr!("transforms/postcss.ts"))
415                .owned()
416                .await?,
417            turbofmt!("?config={postcss_config_path}").await?,
418        )),
419        ReferenceType::Internal(ResolvedVc::cell(fxindexmap! {
420            rcstr!("CONFIG") => config_asset
421        })),
422    ))
423}
424
425async fn find_config_in_location(
426    project_path: FileSystemPath,
427    location: PostCssConfigLocation,
428    source: Vc<Box<dyn Source>>,
429) -> Result<Option<FileSystemPath>> {
430    // Build an ordered list of directories to search based on the location strategy.
431    let search_paths = match location {
432        // Only check project root (used for foreign/node_modules code).
433        PostCssConfigLocation::ProjectPath => {
434            vec![project_path]
435        }
436        // Check project root first, fall back to the CSS file's directory.
437        PostCssConfigLocation::ProjectPathOrLocalPath => {
438            vec![project_path, source.ident().await?.path.parent()]
439        }
440        // Check the CSS file's directory first, fall back to the project root.
441        PostCssConfigLocation::LocalPathOrProjectPath => {
442            vec![source.ident().await?.path.parent(), project_path]
443        }
444    };
445
446    for path in search_paths {
447        if let FindContextFileResult::Found(config_path, _) =
448            &*find_context_file_or_package_key(path, postcss_configs(), rcstr!("postcss")).await?
449        {
450            return Ok(Some(config_path.clone()));
451        }
452    }
453
454    Ok(None)
455}
456
457#[turbo_tasks::value_impl]
458impl GenerateSourceMap for PostCssTransformedAsset {
459    #[turbo_tasks::function]
460    async fn generate_source_map(&self) -> Result<Vc<FileContent>> {
461        let source = ResolvedVc::try_sidecast::<Box<dyn GenerateSourceMap>>(self.source);
462        match source {
463            Some(source) => Ok(source.generate_source_map()),
464            None => Ok(FileContent::NotFound.cell()),
465        }
466    }
467}
468
469#[turbo_tasks::value_impl]
470impl PostCssTransformedAsset {
471    #[turbo_tasks::function]
472    async fn process(&self) -> Result<Vc<ProcessPostCssResult>> {
473        let ExecutionContext {
474            project_path,
475            chunking_context,
476            env,
477            node_backend,
478        } = &*self.execution_context.await?;
479
480        // For this postcss transform, there is no guarantee that looking up for the
481        // source path will arrives specific project config for the postcss.
482        // i.e, this is possible
483        // - root
484        //  - node_modules
485        //     - somepkg/(some.module.css, postcss.config.js) // this could be symlinked local, or
486        //       actual remote pkg or anything
487        //  - packages // root of workspace pkgs
488        //     - pkg1/(postcss.config.js) // The actual config we're looking for
489        //
490        // We look for the config in the project path first, then the source path
491        let Some(config_path) =
492            find_config_in_location(project_path.clone(), self.config_location, *self.source)
493                .await?
494        else {
495            return Ok(ProcessPostCssResult {
496                content: self.source.content().to_resolved().await?,
497                assets: Vec::new(),
498            }
499            .cell());
500        };
501
502        let source_content = self.source.content();
503        let AssetContent::File(file) = *source_content.await? else {
504            bail!("PostCSS transform only support transforming files");
505        };
506        let FileContent::Content(content) = &*file.await? else {
507            return Ok(ProcessPostCssResult {
508                content: AssetContent::File(FileContent::NotFound.resolved_cell()).resolved_cell(),
509                assets: Vec::new(),
510            }
511            .cell());
512        };
513        let content = content.content().to_str()?;
514        let evaluate_context = self.evaluate_context;
515        let source_map = self.source_map;
516
517        // This invalidates the transform when the config changes.
518        let config_changed = config_changed(*self.config_tracing_context, config_path.clone())
519            .to_resolved()
520            .await?;
521
522        let postcss_executor =
523            postcss_executor(*evaluate_context, project_path.clone(), config_path).module();
524
525        let entries =
526            get_evaluate_entries(postcss_executor, *evaluate_context, **node_backend, None)
527                .to_resolved()
528                .await?;
529
530        let module_graph = ModuleGraph::from_graphs(
531            vec![SingleModuleGraph::new_with_entries(
532                entries.graph_entries().to_resolved().await?,
533                false,
534                false,
535            )],
536            None,
537        )
538        .connect()
539        .to_resolved()
540        .await?;
541
542        let source_ident = self.source.ident().await?;
543
544        // We need to get a path relative to the project because the postcss loader
545        // runs with the project as the current working directory.
546        let css_path = if let Some(css_path) = project_path.get_relative_path_to(&source_ident.path)
547        {
548            css_path.into_owned()
549        } else {
550            // This shouldn't be an error since it can happen on virtual assets
551            "".into()
552        };
553
554        let config_value = evaluate_webpack_loader(WebpackLoaderContext {
555            entries,
556            cwd: project_path.clone(),
557            env: *env,
558            node_backend: *node_backend,
559            context_source_for_issue: self.source,
560            chunking_context: *chunking_context,
561            evaluate_context: self.evaluate_context,
562            module_graph,
563            resolve_options_context: None,
564            asset_context: self.asset_context,
565            args: vec![
566                ResolvedVc::cell(content.into()),
567                ResolvedVc::cell(css_path.into()),
568                ResolvedVc::cell(source_map.into()),
569            ],
570            additional_invalidation: config_changed,
571            loader_names: vec![turbo_rcstr::rcstr!("postcss")],
572        })
573        .await?;
574
575        let Some(val) = &*config_value else {
576            // An error happened, which has already been converted into an issue.
577            return Ok(ProcessPostCssResult {
578                content: AssetContent::File(FileContent::NotFound.resolved_cell()).resolved_cell(),
579                assets: Vec::new(),
580            }
581            .cell());
582        };
583        let processed_css: PostCssProcessingResult = parse_json_with_source_context(val)
584            .context("Unable to deserializate response from PostCSS transform operation")?;
585
586        // TODO handle SourceMap
587        let file = File::from(processed_css.css);
588        let assets = emitted_assets_to_virtual_sources(processed_css.assets).await?;
589        let content =
590            AssetContent::File(FileContent::Content(file).resolved_cell()).resolved_cell();
591        Ok(ProcessPostCssResult { content, assets }.cell())
592    }
593}