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