turbopack_node/transforms/
postcss.rs

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