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