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