next_core/
app_structure.rs

1use std::collections::BTreeMap;
2
3use anyhow::{Context, Result, bail};
4use indexmap::map::{Entry, OccupiedEntry};
5use rustc_hash::FxHashMap;
6use serde::{Deserialize, Serialize};
7use tracing::Instrument;
8use turbo_rcstr::{RcStr, rcstr};
9use turbo_tasks::{
10    FxIndexMap, NonLocalValue, ResolvedVc, TaskInput, TryJoinIterExt, ValueDefault, Vc,
11    debug::ValueDebugFormat, fxindexmap, trace::TraceRawVcs,
12};
13use turbo_tasks_fs::{DirectoryContent, DirectoryEntry, FileSystemEntryType, FileSystemPath};
14use turbopack_core::issue::{
15    Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString,
16};
17
18use crate::{
19    next_app::{
20        AppPage, AppPath, PageSegment, PageType,
21        metadata::{
22            GlobalMetadataFileMatch, MetadataFileMatch, match_global_metadata_file,
23            match_local_metadata_file, normalize_metadata_route,
24        },
25    },
26    next_import_map::get_next_package,
27};
28
29// Next.js ignores underscores for routes but you can use %5f to still serve an underscored
30// route.
31fn normalize_underscore(string: &str) -> String {
32    string.replace("%5F", "_")
33}
34
35/// A final route in the app directory.
36#[turbo_tasks::value]
37#[derive(Default, Debug, Clone)]
38pub struct AppDirModules {
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub page: Option<FileSystemPath>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub layout: Option<FileSystemPath>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub error: Option<FileSystemPath>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub global_error: Option<FileSystemPath>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub global_not_found: Option<FileSystemPath>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub loading: Option<FileSystemPath>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub template: Option<FileSystemPath>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub forbidden: Option<FileSystemPath>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub unauthorized: Option<FileSystemPath>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub not_found: Option<FileSystemPath>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub default: Option<FileSystemPath>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub route: Option<FileSystemPath>,
63    #[serde(skip_serializing_if = "Metadata::is_empty", default)]
64    pub metadata: Metadata,
65}
66
67impl AppDirModules {
68    fn without_leaves(&self) -> Self {
69        Self {
70            page: None,
71            layout: self.layout.clone(),
72            error: self.error.clone(),
73            global_error: self.global_error.clone(),
74            global_not_found: self.global_not_found.clone(),
75            loading: self.loading.clone(),
76            template: self.template.clone(),
77            not_found: self.not_found.clone(),
78            forbidden: self.forbidden.clone(),
79            unauthorized: self.unauthorized.clone(),
80            default: None,
81            route: None,
82            metadata: self.metadata.clone(),
83        }
84    }
85}
86
87/// A single metadata file plus an optional "alt" text file.
88#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TraceRawVcs, NonLocalValue)]
89pub enum MetadataWithAltItem {
90    Static {
91        path: FileSystemPath,
92        alt_path: Option<FileSystemPath>,
93    },
94    Dynamic {
95        path: FileSystemPath,
96    },
97}
98
99/// A single metadata file.
100#[derive(
101    Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq, TaskInput, TraceRawVcs, NonLocalValue,
102)]
103pub enum MetadataItem {
104    Static { path: FileSystemPath },
105    Dynamic { path: FileSystemPath },
106}
107
108#[turbo_tasks::function]
109pub async fn get_metadata_route_name(meta: MetadataItem) -> Result<Vc<RcStr>> {
110    Ok(match meta {
111        MetadataItem::Static { path } => Vc::cell(path.file_name().into()),
112        MetadataItem::Dynamic { path } => {
113            let Some(stem) = path.file_stem() else {
114                bail!(
115                    "unable to resolve file stem for metadata item at {}",
116                    path.value_to_string().await?
117                );
118            };
119
120            match stem {
121                "manifest" => Vc::cell(rcstr!("manifest.webmanifest")),
122                _ => Vc::cell(RcStr::from(stem)),
123            }
124        }
125    })
126}
127
128impl MetadataItem {
129    pub fn into_path(self) -> FileSystemPath {
130        match self {
131            MetadataItem::Static { path } => path,
132            MetadataItem::Dynamic { path } => path,
133        }
134    }
135}
136
137impl From<MetadataWithAltItem> for MetadataItem {
138    fn from(value: MetadataWithAltItem) -> Self {
139        match value {
140            MetadataWithAltItem::Static { path, .. } => MetadataItem::Static { path },
141            MetadataWithAltItem::Dynamic { path } => MetadataItem::Dynamic { path },
142        }
143    }
144}
145
146/// Metadata file that can be placed in any segment of the app directory.
147#[derive(
148    Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TraceRawVcs, NonLocalValue,
149)]
150pub struct Metadata {
151    #[serde(skip_serializing_if = "Vec::is_empty", default)]
152    pub icon: Vec<MetadataWithAltItem>,
153    #[serde(skip_serializing_if = "Vec::is_empty", default)]
154    pub apple: Vec<MetadataWithAltItem>,
155    #[serde(skip_serializing_if = "Vec::is_empty", default)]
156    pub twitter: Vec<MetadataWithAltItem>,
157    #[serde(skip_serializing_if = "Vec::is_empty", default)]
158    pub open_graph: Vec<MetadataWithAltItem>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub sitemap: Option<MetadataItem>,
161    // The page indicates where the metadata is defined and captured.
162    // The steps for capturing metadata (get_directory_tree) and constructing
163    // LoaderTree (directory_tree_to_entrypoints) is separated,
164    // and child loader tree can trickle down metadata when clone / merge components calculates
165    // the actual path incorrectly with fillMetadataSegment.
166    //
167    // This is only being used for the static metadata files.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub base_page: Option<AppPage>,
170}
171
172impl Metadata {
173    pub fn is_empty(&self) -> bool {
174        let Metadata {
175            icon,
176            apple,
177            twitter,
178            open_graph,
179            sitemap,
180            base_page: _,
181        } = self;
182        icon.is_empty()
183            && apple.is_empty()
184            && twitter.is_empty()
185            && open_graph.is_empty()
186            && sitemap.is_none()
187    }
188}
189
190/// Metadata files that can be placed in the root of the app directory.
191#[turbo_tasks::value]
192#[derive(Default, Clone, Debug)]
193pub struct GlobalMetadata {
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub favicon: Option<MetadataItem>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub robots: Option<MetadataItem>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub manifest: Option<MetadataItem>,
200}
201
202impl GlobalMetadata {
203    pub fn is_empty(&self) -> bool {
204        let GlobalMetadata {
205            favicon,
206            robots,
207            manifest,
208        } = self;
209        favicon.is_none() && robots.is_none() && manifest.is_none()
210    }
211}
212
213#[turbo_tasks::value]
214#[derive(Debug)]
215pub struct DirectoryTree {
216    /// key is e.g. "dashboard", "(dashboard)", "@slot"
217    pub subdirectories: BTreeMap<RcStr, ResolvedVc<DirectoryTree>>,
218    pub modules: AppDirModules,
219}
220
221#[turbo_tasks::value]
222#[derive(Clone, Debug)]
223struct PlainDirectoryTree {
224    /// key is e.g. "dashboard", "(dashboard)", "@slot"
225    pub subdirectories: BTreeMap<RcStr, PlainDirectoryTree>,
226    pub modules: AppDirModules,
227}
228
229#[turbo_tasks::value_impl]
230impl DirectoryTree {
231    #[turbo_tasks::function]
232    pub async fn into_plain(&self) -> Result<Vc<PlainDirectoryTree>> {
233        let mut subdirectories = BTreeMap::new();
234
235        for (name, subdirectory) in &self.subdirectories {
236            subdirectories.insert(name.clone(), subdirectory.into_plain().owned().await?);
237        }
238
239        Ok(PlainDirectoryTree {
240            subdirectories,
241            modules: self.modules.clone(),
242        }
243        .cell())
244    }
245}
246
247#[turbo_tasks::value(transparent)]
248pub struct OptionAppDir(Option<FileSystemPath>);
249
250/// Finds and returns the [DirectoryTree] of the app directory if existing.
251#[turbo_tasks::function]
252pub async fn find_app_dir(project_path: FileSystemPath) -> Result<Vc<OptionAppDir>> {
253    let app = project_path.join("app")?;
254    let src_app = project_path.join("src/app")?;
255    let app_dir = if *app.get_type().await? == FileSystemEntryType::Directory {
256        app
257    } else if *src_app.get_type().await? == FileSystemEntryType::Directory {
258        src_app
259    } else {
260        return Ok(Vc::cell(None));
261    };
262
263    Ok(Vc::cell(Some(app_dir)))
264}
265
266#[turbo_tasks::function]
267async fn get_directory_tree(
268    dir: FileSystemPath,
269    page_extensions: Vc<Vec<RcStr>>,
270) -> Result<Vc<DirectoryTree>> {
271    let span = {
272        let dir = dir.value_to_string().await?.to_string();
273        tracing::info_span!("read app directory tree", name = dir)
274    };
275    get_directory_tree_internal(dir, page_extensions)
276        .instrument(span)
277        .await
278}
279
280async fn get_directory_tree_internal(
281    dir: FileSystemPath,
282    page_extensions: Vc<Vec<RcStr>>,
283) -> Result<Vc<DirectoryTree>> {
284    let DirectoryContent::Entries(entries) = &*dir.read_dir().await? else {
285        // the file watcher might invalidate things in the wrong order,
286        // and we have to account for the eventual consistency of turbo-tasks
287        // so we just return an empty tree here.
288        return Ok(DirectoryTree {
289            subdirectories: Default::default(),
290            modules: AppDirModules::default(),
291        }
292        .cell());
293    };
294    let page_extensions_value = page_extensions.await?;
295
296    let mut subdirectories = BTreeMap::new();
297    let mut modules = AppDirModules::default();
298
299    let mut metadata_icon = Vec::new();
300    let mut metadata_apple = Vec::new();
301    let mut metadata_open_graph = Vec::new();
302    let mut metadata_twitter = Vec::new();
303
304    for (basename, entry) in entries {
305        let entry = entry.clone().resolve_symlink().await?;
306        match entry {
307            DirectoryEntry::File(file) => {
308                // Do not process .d.ts files as routes
309                if basename.ends_with(".d.ts") {
310                    continue;
311                }
312                if let Some((stem, ext)) = basename.split_once('.')
313                    && page_extensions_value.iter().any(|e| e == ext)
314                {
315                    match stem {
316                        "page" => modules.page = Some(file.clone()),
317                        "layout" => modules.layout = Some(file.clone()),
318                        "error" => modules.error = Some(file.clone()),
319                        "global-error" => modules.global_error = Some(file.clone()),
320                        "global-not-found" => modules.global_not_found = Some(file.clone()),
321                        "loading" => modules.loading = Some(file.clone()),
322                        "template" => modules.template = Some(file.clone()),
323                        "forbidden" => modules.forbidden = Some(file.clone()),
324                        "unauthorized" => modules.unauthorized = Some(file.clone()),
325                        "not-found" => modules.not_found = Some(file.clone()),
326                        "default" => modules.default = Some(file.clone()),
327                        "route" => modules.route = Some(file.clone()),
328                        _ => {}
329                    }
330                }
331
332                let Some(MetadataFileMatch {
333                    metadata_type,
334                    number,
335                    dynamic,
336                }) = match_local_metadata_file(basename.as_str(), &page_extensions_value)
337                else {
338                    continue;
339                };
340
341                let entry = match metadata_type {
342                    "icon" => &mut metadata_icon,
343                    "apple-icon" => &mut metadata_apple,
344                    "twitter-image" => &mut metadata_twitter,
345                    "opengraph-image" => &mut metadata_open_graph,
346                    "sitemap" => {
347                        if dynamic {
348                            modules.metadata.sitemap = Some(MetadataItem::Dynamic { path: file });
349                        } else {
350                            modules.metadata.sitemap = Some(MetadataItem::Static { path: file });
351                        }
352                        continue;
353                    }
354                    _ => continue,
355                };
356
357                if dynamic {
358                    entry.push((number, MetadataWithAltItem::Dynamic { path: file }));
359                    continue;
360                }
361
362                let file_name = file.file_name();
363                let basename = file_name
364                    .rsplit_once('.')
365                    .map_or(file_name, |(basename, _)| basename);
366                let alt_path = file.parent().join(&format!("{basename}.alt.txt"))?;
367                let alt_path = matches!(&*alt_path.get_type().await?, FileSystemEntryType::File)
368                    .then_some(alt_path);
369
370                entry.push((
371                    number,
372                    MetadataWithAltItem::Static {
373                        path: file,
374                        alt_path,
375                    },
376                ));
377            }
378            DirectoryEntry::Directory(dir) => {
379                // appDir ignores paths starting with an underscore
380                if !basename.starts_with('_') {
381                    let result = get_directory_tree(dir.clone(), page_extensions)
382                        .to_resolved()
383                        .await?;
384                    subdirectories.insert(basename.clone(), result);
385                }
386            }
387            // TODO(WEB-952) handle symlinks in app dir
388            _ => {}
389        }
390    }
391
392    fn sort<T>(mut list: Vec<(Option<u32>, T)>) -> Vec<T> {
393        list.sort_by_key(|(num, _)| *num);
394        list.into_iter().map(|(_, item)| item).collect()
395    }
396
397    modules.metadata.icon = sort(metadata_icon);
398    modules.metadata.apple = sort(metadata_apple);
399    modules.metadata.twitter = sort(metadata_twitter);
400    modules.metadata.open_graph = sort(metadata_open_graph);
401
402    Ok(DirectoryTree {
403        subdirectories,
404        modules,
405    }
406    .cell())
407}
408
409#[turbo_tasks::value]
410#[derive(Debug, Clone)]
411pub struct AppPageLoaderTree {
412    pub page: AppPage,
413    pub segment: RcStr,
414    pub parallel_routes: FxIndexMap<RcStr, AppPageLoaderTree>,
415    pub modules: AppDirModules,
416    pub global_metadata: ResolvedVc<GlobalMetadata>,
417}
418
419impl AppPageLoaderTree {
420    /// Returns true if there's a page match in this loader tree.
421    pub fn has_page(&self) -> bool {
422        if &*self.segment == "__PAGE__" {
423            return true;
424        }
425
426        for (_, tree) in &self.parallel_routes {
427            if tree.has_page() {
428                return true;
429            }
430        }
431
432        false
433    }
434
435    /// Returns whether the only match in this tree is for a catch-all
436    /// route.
437    pub fn has_only_catchall(&self) -> bool {
438        if &*self.segment == "__PAGE__" && !self.page.is_catchall() {
439            return false;
440        }
441
442        for (_, tree) in &self.parallel_routes {
443            if !tree.has_only_catchall() {
444                return false;
445            }
446        }
447
448        true
449    }
450
451    /// Returns true if this loader tree contains an intercepting route match.
452    pub fn is_intercepting(&self) -> bool {
453        if self.page.is_intercepting() && self.has_page() {
454            return true;
455        }
456
457        for (_, tree) in &self.parallel_routes {
458            if tree.is_intercepting() {
459                return true;
460            }
461        }
462
463        false
464    }
465
466    /// Returns the specificity of the page (i.e. the number of segments
467    /// affecting the path)
468    pub fn get_specificity(&self) -> usize {
469        if &*self.segment == "__PAGE__" {
470            return AppPath::from(self.page.clone()).len();
471        }
472
473        let mut specificity = 0;
474
475        for (_, tree) in &self.parallel_routes {
476            specificity = specificity.max(tree.get_specificity());
477        }
478
479        specificity
480    }
481}
482
483#[turbo_tasks::value(transparent)]
484pub struct FileSystemPathVec(Vec<FileSystemPath>);
485
486#[turbo_tasks::value_impl]
487impl ValueDefault for FileSystemPathVec {
488    #[turbo_tasks::function]
489    fn value_default() -> Vc<Self> {
490        Vc::cell(Vec::new())
491    }
492}
493
494#[derive(
495    Clone,
496    PartialEq,
497    Eq,
498    Hash,
499    Serialize,
500    Deserialize,
501    TraceRawVcs,
502    ValueDebugFormat,
503    Debug,
504    TaskInput,
505    NonLocalValue,
506)]
507pub enum Entrypoint {
508    AppPage {
509        pages: Vec<AppPage>,
510        loader_tree: ResolvedVc<AppPageLoaderTree>,
511    },
512    AppRoute {
513        page: AppPage,
514        path: FileSystemPath,
515        root_layouts: ResolvedVc<FileSystemPathVec>,
516    },
517    AppMetadata {
518        page: AppPage,
519        metadata: MetadataItem,
520    },
521}
522
523impl Entrypoint {
524    pub fn page(&self) -> &AppPage {
525        match self {
526            Entrypoint::AppPage { pages, .. } => pages.first().unwrap(),
527            Entrypoint::AppRoute { page, .. } => page,
528            Entrypoint::AppMetadata { page, .. } => page,
529        }
530    }
531}
532
533#[turbo_tasks::value(transparent)]
534pub struct Entrypoints(FxIndexMap<AppPath, Entrypoint>);
535
536fn is_parallel_route(name: &str) -> bool {
537    name.starts_with('@')
538}
539
540fn is_group_route(name: &str) -> bool {
541    name.starts_with('(') && name.ends_with(')')
542}
543
544fn match_parallel_route(name: &str) -> Option<&str> {
545    name.strip_prefix('@')
546}
547
548fn conflict_issue(
549    app_dir: FileSystemPath,
550    e: &'_ OccupiedEntry<'_, AppPath, Entrypoint>,
551    a: &str,
552    b: &str,
553    value_a: &AppPage,
554    value_b: &AppPage,
555) {
556    let item_names = if a == b {
557        format!("{a}s")
558    } else {
559        format!("{a} and {b}")
560    };
561
562    DirectoryTreeIssue {
563        app_dir,
564        message: StyledString::Text(
565            format!(
566                "Conflicting {} at {}: {a} at {value_a} and {b} at {value_b}",
567                item_names,
568                e.key(),
569            )
570            .into(),
571        )
572        .resolved_cell(),
573        severity: IssueSeverity::Error,
574    }
575    .resolved_cell()
576    .emit();
577}
578
579fn add_app_page(
580    app_dir: FileSystemPath,
581    result: &mut FxIndexMap<AppPath, Entrypoint>,
582    page: AppPage,
583    loader_tree: ResolvedVc<AppPageLoaderTree>,
584) {
585    let mut e = match result.entry(page.clone().into()) {
586        Entry::Occupied(e) => e,
587        Entry::Vacant(e) => {
588            e.insert(Entrypoint::AppPage {
589                pages: vec![page],
590                loader_tree,
591            });
592            return;
593        }
594    };
595
596    let conflict = |existing_name: &str, existing_page: &AppPage| {
597        conflict_issue(app_dir, &e, "page", existing_name, &page, existing_page);
598    };
599
600    let value = e.get();
601    match value {
602        Entrypoint::AppPage {
603            pages: existing_pages,
604            loader_tree: existing_loader_tree,
605        } => {
606            // loader trees should always match for the same path as they are generated by a
607            // turbo tasks function
608            if *existing_loader_tree != loader_tree {
609                conflict("page", existing_pages.first().unwrap());
610            }
611
612            let Entrypoint::AppPage {
613                pages: stored_pages,
614                ..
615            } = e.get_mut()
616            else {
617                unreachable!("Entrypoint::AppPage was already matched");
618            };
619
620            stored_pages.push(page);
621            stored_pages.sort();
622        }
623        Entrypoint::AppRoute {
624            page: existing_page,
625            ..
626        } => {
627            conflict("route", existing_page);
628        }
629        Entrypoint::AppMetadata {
630            page: existing_page,
631            ..
632        } => {
633            conflict("metadata", existing_page);
634        }
635    }
636}
637
638fn add_app_route(
639    app_dir: FileSystemPath,
640    result: &mut FxIndexMap<AppPath, Entrypoint>,
641    page: AppPage,
642    path: FileSystemPath,
643    root_layouts: ResolvedVc<FileSystemPathVec>,
644) {
645    let e = match result.entry(page.clone().into()) {
646        Entry::Occupied(e) => e,
647        Entry::Vacant(e) => {
648            e.insert(Entrypoint::AppRoute {
649                page,
650                path,
651                root_layouts,
652            });
653            return;
654        }
655    };
656
657    let conflict = |existing_name: &str, existing_page: &AppPage| {
658        conflict_issue(app_dir, &e, "route", existing_name, &page, existing_page);
659    };
660
661    let value = e.get();
662    match value {
663        Entrypoint::AppPage { pages, .. } => {
664            conflict("page", pages.first().unwrap());
665        }
666        Entrypoint::AppRoute {
667            page: existing_page,
668            ..
669        } => {
670            conflict("route", existing_page);
671        }
672        Entrypoint::AppMetadata {
673            page: existing_page,
674            ..
675        } => {
676            conflict("metadata", existing_page);
677        }
678    }
679}
680
681fn add_app_metadata_route(
682    app_dir: FileSystemPath,
683    result: &mut FxIndexMap<AppPath, Entrypoint>,
684    page: AppPage,
685    metadata: MetadataItem,
686) {
687    let e = match result.entry(page.clone().into()) {
688        Entry::Occupied(e) => e,
689        Entry::Vacant(e) => {
690            e.insert(Entrypoint::AppMetadata { page, metadata });
691            return;
692        }
693    };
694
695    let conflict = |existing_name: &str, existing_page: &AppPage| {
696        conflict_issue(app_dir, &e, "metadata", existing_name, &page, existing_page);
697    };
698
699    let value = e.get();
700    match value {
701        Entrypoint::AppPage { pages, .. } => {
702            conflict("page", pages.first().unwrap());
703        }
704        Entrypoint::AppRoute {
705            page: existing_page,
706            ..
707        } => {
708            conflict("route", existing_page);
709        }
710        Entrypoint::AppMetadata {
711            page: existing_page,
712            ..
713        } => {
714            conflict("metadata", existing_page);
715        }
716    }
717}
718
719#[turbo_tasks::function]
720pub fn get_entrypoints(
721    app_dir: FileSystemPath,
722    page_extensions: Vc<Vec<RcStr>>,
723    is_global_not_found_enabled: Vc<bool>,
724) -> Vc<Entrypoints> {
725    directory_tree_to_entrypoints(
726        app_dir.clone(),
727        get_directory_tree(app_dir.clone(), page_extensions),
728        get_global_metadata(app_dir, page_extensions),
729        is_global_not_found_enabled,
730        Default::default(),
731    )
732}
733
734#[turbo_tasks::function]
735fn directory_tree_to_entrypoints(
736    app_dir: FileSystemPath,
737    directory_tree: Vc<DirectoryTree>,
738    global_metadata: Vc<GlobalMetadata>,
739    is_global_not_found_enabled: Vc<bool>,
740    root_layouts: Vc<FileSystemPathVec>,
741) -> Vc<Entrypoints> {
742    directory_tree_to_entrypoints_internal(
743        app_dir,
744        global_metadata,
745        is_global_not_found_enabled,
746        rcstr!(""),
747        directory_tree,
748        AppPage::new(),
749        root_layouts,
750    )
751}
752
753#[turbo_tasks::value]
754struct DuplicateParallelRouteIssue {
755    app_dir: FileSystemPath,
756    previously_inserted_page: AppPage,
757    page: AppPage,
758}
759
760#[turbo_tasks::value_impl]
761impl Issue for DuplicateParallelRouteIssue {
762    #[turbo_tasks::function]
763    fn file_path(&self) -> Result<Vc<FileSystemPath>> {
764        Ok(self.app_dir.join(&self.page.to_string())?.cell())
765    }
766
767    #[turbo_tasks::function]
768    fn stage(self: Vc<Self>) -> Vc<IssueStage> {
769        IssueStage::ProcessModule.cell()
770    }
771
772    #[turbo_tasks::function]
773    async fn title(self: Vc<Self>) -> Result<Vc<StyledString>> {
774        let this = self.await?;
775        Ok(StyledString::Text(
776            format!(
777                "You cannot have two parallel pages that resolve to the same path. Please check \
778                 {} and {}.",
779                this.previously_inserted_page, this.page
780            )
781            .into(),
782        )
783        .cell())
784    }
785}
786
787fn page_path_except_parallel(loader_tree: &AppPageLoaderTree) -> Option<AppPage> {
788    if loader_tree.page.iter().any(|v| {
789        matches!(
790            v,
791            PageSegment::CatchAll(..)
792                | PageSegment::OptionalCatchAll(..)
793                | PageSegment::Parallel(..)
794        )
795    }) {
796        return None;
797    }
798
799    if loader_tree.modules.page.is_some() {
800        return Some(loader_tree.page.clone());
801    }
802
803    if let Some(children) = loader_tree.parallel_routes.get("children") {
804        return page_path_except_parallel(children);
805    }
806
807    None
808}
809
810async fn check_duplicate(
811    duplicate: &mut FxHashMap<AppPath, AppPage>,
812    loader_tree: &AppPageLoaderTree,
813    app_dir: FileSystemPath,
814) -> Result<()> {
815    let page_path = page_path_except_parallel(loader_tree);
816
817    if let Some(page_path) = page_path
818        && let Some(prev) = duplicate.insert(AppPath::from(page_path.clone()), page_path.clone())
819        && prev != page_path
820    {
821        DuplicateParallelRouteIssue {
822            app_dir: app_dir.clone(),
823            previously_inserted_page: prev.clone(),
824            page: loader_tree.page.clone(),
825        }
826        .resolved_cell()
827        .emit();
828    }
829
830    Ok(())
831}
832
833#[turbo_tasks::value(transparent)]
834struct AppPageLoaderTreeOption(Option<ResolvedVc<AppPageLoaderTree>>);
835
836/// creates the loader tree for a specific route (pathname / [AppPath])
837#[turbo_tasks::function]
838async fn directory_tree_to_loader_tree(
839    app_dir: FileSystemPath,
840    global_metadata: Vc<GlobalMetadata>,
841    directory_name: RcStr,
842    directory_tree: Vc<DirectoryTree>,
843    app_page: AppPage,
844    // the page this loader tree is constructed for
845    for_app_path: AppPath,
846) -> Result<Vc<AppPageLoaderTreeOption>> {
847    let plain_tree = &*directory_tree.into_plain().await?;
848
849    let tree = directory_tree_to_loader_tree_internal(
850        app_dir,
851        global_metadata,
852        directory_name,
853        plain_tree,
854        app_page,
855        for_app_path,
856    )
857    .await?;
858
859    Ok(Vc::cell(tree.map(AppPageLoaderTree::resolved_cell)))
860}
861
862async fn directory_tree_to_loader_tree_internal(
863    app_dir: FileSystemPath,
864    global_metadata: Vc<GlobalMetadata>,
865    directory_name: RcStr,
866    directory_tree: &PlainDirectoryTree,
867    app_page: AppPage,
868    // the page this loader tree is constructed for
869    for_app_path: AppPath,
870) -> Result<Option<AppPageLoaderTree>> {
871    let app_path = AppPath::from(app_page.clone());
872
873    if !for_app_path.contains(&app_path) {
874        return Ok(None);
875    }
876
877    let mut modules = directory_tree.modules.clone();
878
879    // Capture the current page for the metadata to calculate segment relative to
880    // the corresponding page for the static metadata files.
881    modules.metadata.base_page = Some(app_page.clone());
882
883    // the root directory in the app dir.
884    let is_root_directory = app_page.is_root();
885    // an alternative root layout (in a route group which affects the page, but not
886    // the path).
887    let is_root_layout = app_path.is_root() && modules.layout.is_some();
888
889    if is_root_directory || is_root_layout {
890        if modules.not_found.is_none() {
891            modules.not_found = Some(
892                get_next_package(app_dir.clone())
893                    .await?
894                    .join("dist/client/components/builtin/not-found.js")?,
895            );
896        }
897        if modules.forbidden.is_none() {
898            modules.forbidden = Some(
899                get_next_package(app_dir.clone())
900                    .await?
901                    .join("dist/client/components/builtin/forbidden.js")?,
902            );
903        }
904        if modules.unauthorized.is_none() {
905            modules.unauthorized = Some(
906                get_next_package(app_dir.clone())
907                    .await?
908                    .join("dist/client/components/builtin/unauthorized.js")?,
909            );
910        }
911        if modules.global_error.is_none() {
912            modules.global_error = Some(
913                get_next_package(app_dir.clone())
914                    .await?
915                    .join("dist/client/components/builtin/global-error.js")?,
916            );
917        }
918    }
919
920    let mut tree = AppPageLoaderTree {
921        page: app_page.clone(),
922        segment: directory_name.clone(),
923        parallel_routes: FxIndexMap::default(),
924        modules: modules.without_leaves(),
925        global_metadata: global_metadata.to_resolved().await?,
926    };
927
928    let current_level_is_parallel_route = is_parallel_route(&directory_name);
929
930    if current_level_is_parallel_route {
931        tree.segment = rcstr!("children");
932    }
933
934    if let Some(page) = (app_path == for_app_path || app_path.is_catchall())
935        .then_some(modules.page)
936        .flatten()
937    {
938        tree.parallel_routes.insert(
939            rcstr!("children"),
940            AppPageLoaderTree {
941                page: app_page.clone(),
942                segment: rcstr!("__PAGE__"),
943                parallel_routes: FxIndexMap::default(),
944                modules: AppDirModules {
945                    page: Some(page),
946                    metadata: modules.metadata,
947                    ..Default::default()
948                },
949                global_metadata: global_metadata.to_resolved().await?,
950            },
951        );
952
953        if current_level_is_parallel_route {
954            tree.segment = rcstr!("page$");
955        }
956    }
957
958    let mut duplicate = FxHashMap::default();
959
960    for (subdir_name, subdirectory) in &directory_tree.subdirectories {
961        let parallel_route_key = match_parallel_route(subdir_name);
962
963        let mut child_app_page = app_page.clone();
964        let mut illegal_path_error = None;
965
966        // When constructing the app_page fails (e. g. due to limitations of the order),
967        // we only want to emit the error when there are actual pages below that
968        // directory.
969        if let Err(e) = child_app_page.push_str(&normalize_underscore(subdir_name)) {
970            illegal_path_error = Some(e);
971        }
972
973        let subtree = Box::pin(directory_tree_to_loader_tree_internal(
974            app_dir.clone(),
975            global_metadata,
976            subdir_name.clone(),
977            subdirectory,
978            child_app_page.clone(),
979            for_app_path.clone(),
980        ))
981        .await?;
982
983        if let Some(illegal_path) = subtree.as_ref().and(illegal_path_error) {
984            return Err(illegal_path);
985        }
986
987        if let Some(subtree) = subtree {
988            if let Some(key) = parallel_route_key {
989                tree.parallel_routes.insert(key.into(), subtree);
990                continue;
991            }
992
993            // skip groups which don't have a page match.
994            if is_group_route(subdir_name) && !subtree.has_page() {
995                continue;
996            }
997
998            if subtree.has_page() {
999                check_duplicate(&mut duplicate, &subtree, app_dir.clone()).await?;
1000            }
1001
1002            if let Some(current_tree) = tree.parallel_routes.get("children") {
1003                if current_tree.has_only_catchall()
1004                    && (!subtree.has_only_catchall()
1005                        || current_tree.get_specificity() < subtree.get_specificity())
1006                {
1007                    tree.parallel_routes
1008                        .insert(rcstr!("children"), subtree.clone());
1009                }
1010            } else {
1011                tree.parallel_routes.insert(rcstr!("children"), subtree);
1012            }
1013        } else if let Some(key) = parallel_route_key {
1014            bail!(
1015                "missing page or default for parallel route `{}` (page: {})",
1016                key,
1017                app_page
1018            );
1019        }
1020    }
1021
1022    // make sure we don't have a match for other slots if there's an intercepting route match
1023    // we only check subtrees as the current level could trigger `is_intercepting`
1024    if tree
1025        .parallel_routes
1026        .iter()
1027        .any(|(_, parallel_tree)| parallel_tree.is_intercepting())
1028    {
1029        let mut keys_to_replace = Vec::new();
1030
1031        for (key, parallel_tree) in &tree.parallel_routes {
1032            if !parallel_tree.is_intercepting() {
1033                keys_to_replace.push(key.clone());
1034            }
1035        }
1036
1037        for key in keys_to_replace {
1038            let subdir_name: RcStr = format!("@{key}").into();
1039
1040            let default = if key == "children" {
1041                modules.default.clone()
1042            } else if let Some(subdirectory) = directory_tree.subdirectories.get(&subdir_name) {
1043                subdirectory.modules.default.clone()
1044            } else {
1045                None
1046            };
1047
1048            tree.parallel_routes.insert(
1049                key,
1050                default_route_tree(app_dir.clone(), global_metadata, app_page.clone(), default)
1051                    .await?,
1052            );
1053        }
1054    }
1055
1056    if tree.parallel_routes.is_empty() {
1057        if modules.default.is_some() || current_level_is_parallel_route {
1058            tree = default_route_tree(
1059                app_dir.clone(),
1060                global_metadata,
1061                app_page,
1062                modules.default.clone(),
1063            )
1064            .await?;
1065        } else {
1066            return Ok(None);
1067        }
1068    } else if tree.parallel_routes.get("children").is_none() {
1069        tree.parallel_routes.insert(
1070            rcstr!("children"),
1071            default_route_tree(
1072                app_dir.clone(),
1073                global_metadata,
1074                app_page,
1075                modules.default.clone(),
1076            )
1077            .await?,
1078        );
1079    }
1080
1081    if tree.parallel_routes.len() > 1
1082        && tree.parallel_routes.keys().next().map(|s| s.as_str()) != Some("children")
1083    {
1084        // children must go first for next.js to work correctly
1085        tree.parallel_routes
1086            .move_index(tree.parallel_routes.len() - 1, 0);
1087    }
1088
1089    Ok(Some(tree))
1090}
1091
1092async fn default_route_tree(
1093    app_dir: FileSystemPath,
1094    global_metadata: Vc<GlobalMetadata>,
1095    app_page: AppPage,
1096    default_component: Option<FileSystemPath>,
1097) -> Result<AppPageLoaderTree> {
1098    Ok(AppPageLoaderTree {
1099        page: app_page.clone(),
1100        segment: rcstr!("__DEFAULT__"),
1101        parallel_routes: FxIndexMap::default(),
1102        modules: if let Some(default) = default_component {
1103            AppDirModules {
1104                default: Some(default),
1105                ..Default::default()
1106            }
1107        } else {
1108            // default fallback component
1109            AppDirModules {
1110                default: Some(
1111                    get_next_package(app_dir)
1112                        .await?
1113                        .join("dist/client/components/builtin/default.js")?,
1114                ),
1115                ..Default::default()
1116            }
1117        },
1118        global_metadata: global_metadata.to_resolved().await?,
1119    })
1120}
1121
1122#[turbo_tasks::function]
1123async fn directory_tree_to_entrypoints_internal(
1124    app_dir: FileSystemPath,
1125    global_metadata: ResolvedVc<GlobalMetadata>,
1126    is_global_not_found_enabled: Vc<bool>,
1127    directory_name: RcStr,
1128    directory_tree: Vc<DirectoryTree>,
1129    app_page: AppPage,
1130    root_layouts: ResolvedVc<FileSystemPathVec>,
1131) -> Result<Vc<Entrypoints>> {
1132    let span = tracing::info_span!("build layout trees", name = display(&app_page));
1133    directory_tree_to_entrypoints_internal_untraced(
1134        app_dir,
1135        global_metadata,
1136        is_global_not_found_enabled,
1137        directory_name,
1138        directory_tree,
1139        app_page,
1140        root_layouts,
1141    )
1142    .instrument(span)
1143    .await
1144}
1145
1146async fn directory_tree_to_entrypoints_internal_untraced(
1147    app_dir: FileSystemPath,
1148    global_metadata: ResolvedVc<GlobalMetadata>,
1149    is_global_not_found_enabled: Vc<bool>,
1150    directory_name: RcStr,
1151    directory_tree: Vc<DirectoryTree>,
1152    app_page: AppPage,
1153    root_layouts: ResolvedVc<FileSystemPathVec>,
1154) -> Result<Vc<Entrypoints>> {
1155    let mut result = FxIndexMap::default();
1156
1157    let directory_tree_vc = directory_tree;
1158    let directory_tree = &*directory_tree.await?;
1159
1160    let subdirectories = &directory_tree.subdirectories;
1161    let modules = &directory_tree.modules;
1162    // Route can have its own segment config, also can inherit from the layout root
1163    // segment config. https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes#segment-runtime-option
1164    // Pass down layouts from each tree to apply segment config when adding route.
1165    let root_layouts = if let Some(layout) = &modules.layout {
1166        let mut layouts = root_layouts.owned().await?;
1167        layouts.push(layout.clone());
1168        ResolvedVc::cell(layouts)
1169    } else {
1170        root_layouts
1171    };
1172
1173    if modules.page.is_some() {
1174        let app_path = AppPath::from(app_page.clone());
1175
1176        let loader_tree = *directory_tree_to_loader_tree(
1177            app_dir.clone(),
1178            *global_metadata,
1179            directory_name.clone(),
1180            directory_tree_vc,
1181            app_page.clone(),
1182            app_path,
1183        )
1184        .await?;
1185
1186        add_app_page(
1187            app_dir.clone(),
1188            &mut result,
1189            app_page.complete(PageType::Page)?,
1190            loader_tree.context("loader tree should be created for a page/default")?,
1191        );
1192    }
1193
1194    if let Some(route) = &modules.route {
1195        add_app_route(
1196            app_dir.clone(),
1197            &mut result,
1198            app_page.complete(PageType::Route)?,
1199            route.clone(),
1200            root_layouts,
1201        );
1202    }
1203
1204    let Metadata {
1205        icon,
1206        apple,
1207        twitter,
1208        open_graph,
1209        sitemap,
1210        base_page: _,
1211    } = &modules.metadata;
1212
1213    for meta in sitemap
1214        .iter()
1215        .cloned()
1216        .chain(icon.iter().cloned().map(MetadataItem::from))
1217        .chain(apple.iter().cloned().map(MetadataItem::from))
1218        .chain(twitter.iter().cloned().map(MetadataItem::from))
1219        .chain(open_graph.iter().cloned().map(MetadataItem::from))
1220    {
1221        let app_page = app_page.clone_push_str(&get_metadata_route_name(meta.clone()).await?)?;
1222
1223        add_app_metadata_route(
1224            app_dir.clone(),
1225            &mut result,
1226            normalize_metadata_route(app_page)?,
1227            meta,
1228        );
1229    }
1230
1231    // root path: /
1232    if app_page.is_root() {
1233        let GlobalMetadata {
1234            favicon,
1235            robots,
1236            manifest,
1237        } = &*global_metadata.await?;
1238
1239        for meta in favicon.iter().chain(robots.iter()).chain(manifest.iter()) {
1240            let app_page =
1241                app_page.clone_push_str(&get_metadata_route_name(meta.clone()).await?)?;
1242
1243            add_app_metadata_route(
1244                app_dir.clone(),
1245                &mut result,
1246                normalize_metadata_route(app_page)?,
1247                meta.clone(),
1248            );
1249        }
1250
1251        let mut modules = directory_tree.modules.clone();
1252
1253        // fill in the default modules for the not-found entrypoint
1254        if modules.layout.is_none() {
1255            modules.layout = Some(
1256                get_next_package(app_dir.clone())
1257                    .await?
1258                    .join("dist/client/components/builtin/layout.js")?,
1259            );
1260        }
1261
1262        if modules.not_found.is_none() {
1263            modules.not_found = Some(
1264                get_next_package(app_dir.clone())
1265                    .await?
1266                    .join("dist/client/components/builtin/not-found.js")?,
1267            );
1268        }
1269        if modules.forbidden.is_none() {
1270            modules.forbidden = Some(
1271                get_next_package(app_dir.clone())
1272                    .await?
1273                    .join("dist/client/components/builtin/forbidden.js")?,
1274            );
1275        }
1276        if modules.unauthorized.is_none() {
1277            modules.unauthorized = Some(
1278                get_next_package(app_dir.clone())
1279                    .await?
1280                    .join("dist/client/components/builtin/unauthorized.js")?,
1281            );
1282        }
1283
1284        // Next.js has this logic in "collect-app-paths", where the root not-found page
1285        // is considered as its own entry point.
1286
1287        // Determine if we enable the global not-found feature.
1288        let is_global_not_found_enabled = *is_global_not_found_enabled.await?;
1289        let use_global_not_found =
1290            is_global_not_found_enabled || modules.global_not_found.is_some();
1291
1292        let not_found_root_modules = modules.without_leaves();
1293        let not_found_tree = AppPageLoaderTree {
1294            page: app_page.clone(),
1295            segment: directory_name.clone(),
1296            parallel_routes: fxindexmap! {
1297                rcstr!("children") => AppPageLoaderTree {
1298                    page: app_page.clone(),
1299                    segment: rcstr!("/_not-found"),
1300                    parallel_routes: fxindexmap! {
1301                        rcstr!("children") => AppPageLoaderTree {
1302                            page: app_page.clone(),
1303                            segment: rcstr!("__PAGE__"),
1304                            parallel_routes: FxIndexMap::default(),
1305                            modules: if use_global_not_found {
1306                                // if global-not-found.js is present:
1307                                // we use it for the page and no layout, since layout is included in global-not-found.js;
1308                                AppDirModules {
1309                                    layout: None,
1310                                    page: match modules.global_not_found {
1311                                        Some(v) => Some(v),
1312                                        None =>  Some(get_next_package(app_dir.clone())
1313                                            .await?
1314                                            .join("dist/client/components/builtin/global-not-found.js")?,
1315                                        ),
1316                                    },
1317                                    ..Default::default()
1318                                }
1319                            } else {
1320                                // if global-not-found.js is not present:
1321                                // we search if we can compose root layout with the root not-found.js;
1322                                AppDirModules {
1323                                    page: match modules.not_found {
1324                                        Some(v) => Some(v),
1325                                        None => Some(get_next_package(app_dir.clone())
1326                                            .await?
1327                                            .join("dist/client/components/builtin/not-found.js")?,
1328                                        ),
1329                                    },
1330                                    ..Default::default()
1331                                }
1332                            },
1333                            global_metadata,
1334                        }
1335                    },
1336                    modules: AppDirModules {
1337                        ..Default::default()
1338                    },
1339                    global_metadata,
1340                },
1341            },
1342            modules: AppDirModules {
1343                // `global-not-found.js` does not need a layout since it's included.
1344                // Skip it if it's present.
1345                // Otherwise, we need to compose it with the root layout to compose with
1346                // not-found.js boundary.
1347                layout: if use_global_not_found {
1348                    None
1349                } else {
1350                    modules.layout
1351                },
1352                ..not_found_root_modules
1353            },
1354            global_metadata,
1355        }
1356        .resolved_cell();
1357
1358        {
1359            let app_page = app_page
1360                .clone_push_str("_not-found")?
1361                .complete(PageType::Page)?;
1362
1363            add_app_page(app_dir.clone(), &mut result, app_page, not_found_tree);
1364        }
1365    }
1366
1367    let app_page = &app_page;
1368    let directory_name = &directory_name;
1369    let subdirectories = subdirectories
1370        .iter()
1371        .map(|(subdir_name, &subdirectory)| {
1372            let app_dir = app_dir.clone();
1373
1374            async move {
1375                let mut child_app_page = app_page.clone();
1376                let mut illegal_path = None;
1377
1378                // When constructing the app_page fails (e. g. due to limitations of the order),
1379                // we only want to emit the error when there are actual pages below that
1380                // directory.
1381                if let Err(e) = child_app_page.push_str(&normalize_underscore(subdir_name)) {
1382                    illegal_path = Some(e);
1383                }
1384
1385                let map = directory_tree_to_entrypoints_internal(
1386                    app_dir.clone(),
1387                    *global_metadata,
1388                    is_global_not_found_enabled,
1389                    subdir_name.clone(),
1390                    *subdirectory,
1391                    child_app_page.clone(),
1392                    *root_layouts,
1393                )
1394                .await?;
1395
1396                if let Some(illegal_path) = illegal_path
1397                    && !map.is_empty()
1398                {
1399                    return Err(illegal_path);
1400                }
1401
1402                let mut loader_trees = Vec::new();
1403
1404                for (_, entrypoint) in map.iter() {
1405                    if let Entrypoint::AppPage {
1406                        ref pages,
1407                        loader_tree: _,
1408                    } = *entrypoint
1409                    {
1410                        for page in pages {
1411                            let app_path = AppPath::from(page.clone());
1412
1413                            let loader_tree = directory_tree_to_loader_tree(
1414                                app_dir.clone(),
1415                                *global_metadata,
1416                                directory_name.clone(),
1417                                directory_tree_vc,
1418                                app_page.clone(),
1419                                app_path,
1420                            );
1421                            loader_trees.push(loader_tree);
1422                        }
1423                    }
1424                }
1425                Ok((map, loader_trees))
1426            }
1427        })
1428        .try_join()
1429        .await?;
1430
1431    for (map, loader_trees) in subdirectories.iter() {
1432        let mut i = 0;
1433        for (_, entrypoint) in map.iter() {
1434            match entrypoint {
1435                Entrypoint::AppPage {
1436                    pages,
1437                    loader_tree: _,
1438                } => {
1439                    for page in pages {
1440                        let loader_tree = *loader_trees[i].await?;
1441                        i += 1;
1442
1443                        add_app_page(
1444                            app_dir.clone(),
1445                            &mut result,
1446                            page.clone(),
1447                            loader_tree
1448                                .context("loader tree should be created for a page/default")?,
1449                        );
1450                    }
1451                }
1452                Entrypoint::AppRoute {
1453                    page,
1454                    path,
1455                    root_layouts,
1456                } => {
1457                    add_app_route(
1458                        app_dir.clone(),
1459                        &mut result,
1460                        page.clone(),
1461                        path.clone(),
1462                        *root_layouts,
1463                    );
1464                }
1465                Entrypoint::AppMetadata { page, metadata } => {
1466                    add_app_metadata_route(
1467                        app_dir.clone(),
1468                        &mut result,
1469                        page.clone(),
1470                        metadata.clone(),
1471                    );
1472                }
1473            }
1474        }
1475    }
1476    Ok(Vc::cell(result))
1477}
1478
1479/// Returns the global metadata for an app directory.
1480#[turbo_tasks::function]
1481pub async fn get_global_metadata(
1482    app_dir: FileSystemPath,
1483    page_extensions: Vc<Vec<RcStr>>,
1484) -> Result<Vc<GlobalMetadata>> {
1485    let DirectoryContent::Entries(entries) = &*app_dir.read_dir().await? else {
1486        bail!("app_dir must be a directory")
1487    };
1488    let mut metadata = GlobalMetadata::default();
1489
1490    for (basename, entry) in entries {
1491        let DirectoryEntry::File(file) = entry else {
1492            continue;
1493        };
1494
1495        let Some(GlobalMetadataFileMatch {
1496            metadata_type,
1497            dynamic,
1498        }) = match_global_metadata_file(basename, &page_extensions.await?)
1499        else {
1500            continue;
1501        };
1502
1503        let entry = match metadata_type {
1504            "favicon" => &mut metadata.favicon,
1505            "manifest" => &mut metadata.manifest,
1506            "robots" => &mut metadata.robots,
1507            _ => continue,
1508        };
1509
1510        if dynamic {
1511            *entry = Some(MetadataItem::Dynamic { path: file.clone() });
1512        } else {
1513            *entry = Some(MetadataItem::Static { path: file.clone() });
1514        }
1515        // TODO(WEB-952) handle symlinks in app dir
1516    }
1517
1518    Ok(metadata.cell())
1519}
1520
1521#[turbo_tasks::value(shared)]
1522struct DirectoryTreeIssue {
1523    pub severity: IssueSeverity,
1524    pub app_dir: FileSystemPath,
1525    pub message: ResolvedVc<StyledString>,
1526}
1527
1528#[turbo_tasks::value_impl]
1529impl Issue for DirectoryTreeIssue {
1530    fn severity(&self) -> IssueSeverity {
1531        self.severity
1532    }
1533
1534    #[turbo_tasks::function]
1535    fn title(&self) -> Vc<StyledString> {
1536        StyledString::Text(rcstr!("An issue occurred while preparing your Next.js app")).cell()
1537    }
1538
1539    #[turbo_tasks::function]
1540    fn stage(&self) -> Vc<IssueStage> {
1541        IssueStage::AppStructure.cell()
1542    }
1543
1544    #[turbo_tasks::function]
1545    fn file_path(&self) -> Vc<FileSystemPath> {
1546        self.app_dir.clone().cell()
1547    }
1548
1549    #[turbo_tasks::function]
1550    fn description(&self) -> Vc<OptionStyledString> {
1551        Vc::cell(Some(self.message))
1552    }
1553}