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