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