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