Skip to main content

next_core/
app_structure.rs

1use std::collections::BTreeMap;
2
3use anyhow::{Context, Result, bail};
4use async_trait::async_trait;
5use bincode::{Decode, Encode};
6use indexmap::map::{Entry, OccupiedEntry};
7use rustc_hash::FxHashMap;
8use tracing::Instrument;
9use turbo_rcstr::{RcStr, rcstr};
10use turbo_tasks::{
11    FxIndexMap, FxIndexSet, NonLocalValue, ResolvedVc, TryJoinIterExt, ValueDefault,
12    ValueToStringRef, Vc, debug::ValueDebugFormat, fxindexmap, turbobail,
13};
14use turbo_tasks_fs::{DirectoryContent, DirectoryEntry, FileSystemEntryType, FileSystemPath};
15use turbopack_core::issue::{Issue, IssueExt, IssueSeverity, IssueStage, StyledString};
16
17use crate::{
18    mode::NextMode,
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    pub page: Option<FileSystemPath>,
40    pub layout: Option<FileSystemPath>,
41    pub error: Option<FileSystemPath>,
42    pub global_error: Option<FileSystemPath>,
43    pub global_not_found: Option<FileSystemPath>,
44    pub loading: Option<FileSystemPath>,
45    pub template: Option<FileSystemPath>,
46    pub forbidden: Option<FileSystemPath>,
47    pub unauthorized: Option<FileSystemPath>,
48    pub not_found: Option<FileSystemPath>,
49    pub default: Option<FileSystemPath>,
50    pub route: Option<FileSystemPath>,
51    pub metadata: Metadata,
52}
53
54impl AppDirModules {
55    fn without_leaves(&self) -> Self {
56        Self {
57            page: None,
58            layout: self.layout.clone(),
59            error: self.error.clone(),
60            global_error: self.global_error.clone(),
61            global_not_found: self.global_not_found.clone(),
62            loading: self.loading.clone(),
63            template: self.template.clone(),
64            not_found: self.not_found.clone(),
65            forbidden: self.forbidden.clone(),
66            unauthorized: self.unauthorized.clone(),
67            default: None,
68            route: None,
69            metadata: self.metadata.clone(),
70        }
71    }
72}
73
74/// A single metadata file plus an optional "alt" text file.
75#[derive(Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]
76pub enum MetadataWithAltItem {
77    Static {
78        path: FileSystemPath,
79        alt_path: Option<FileSystemPath>,
80    },
81    Dynamic {
82        path: FileSystemPath,
83    },
84}
85
86/// A single metadata file.
87#[turbo_tasks::task_input]
88#[derive(Clone, Debug, Hash, PartialEq, Eq, Encode, Decode)]
89pub enum MetadataItem {
90    Static { path: FileSystemPath },
91    Dynamic { path: FileSystemPath },
92}
93
94#[turbo_tasks::function]
95pub async fn get_metadata_route_name(meta: MetadataItem) -> Result<Vc<RcStr>> {
96    Ok(match meta {
97        MetadataItem::Static { path } => Vc::cell(path.file_name().into()),
98        MetadataItem::Dynamic { path } => {
99            let Some(stem) = path.file_stem() else {
100                turbobail!("unable to resolve file stem for metadata item at {path}");
101            };
102
103            match stem {
104                "manifest" => Vc::cell(rcstr!("manifest.webmanifest")),
105                _ => Vc::cell(RcStr::from(stem)),
106            }
107        }
108    })
109}
110
111impl MetadataItem {
112    pub fn into_path(self) -> FileSystemPath {
113        match self {
114            MetadataItem::Static { path } => path,
115            MetadataItem::Dynamic { path } => path,
116        }
117    }
118}
119
120impl From<MetadataWithAltItem> for MetadataItem {
121    fn from(value: MetadataWithAltItem) -> Self {
122        match value {
123            MetadataWithAltItem::Static { path, .. } => MetadataItem::Static { path },
124            MetadataWithAltItem::Dynamic { path } => MetadataItem::Dynamic { path },
125        }
126    }
127}
128
129/// Metadata file that can be placed in any segment of the app directory.
130#[derive(Default, Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]
131pub struct Metadata {
132    pub icon: Vec<MetadataWithAltItem>,
133    pub apple: Vec<MetadataWithAltItem>,
134    pub twitter: Vec<MetadataWithAltItem>,
135    pub open_graph: Vec<MetadataWithAltItem>,
136    pub sitemap: Option<MetadataItem>,
137    // The page indicates where the metadata is defined and captured.
138    // The steps for capturing metadata (get_directory_tree) and constructing
139    // LoaderTree (directory_tree_to_entrypoints) is separated,
140    // and child loader tree can trickle down metadata when clone / merge components calculates
141    // the actual path incorrectly with fillMetadataSegment.
142    //
143    // This is only being used for the static metadata files.
144    pub base_page: Option<AppPage>,
145}
146
147impl Metadata {
148    pub fn is_empty(&self) -> bool {
149        let Metadata {
150            icon,
151            apple,
152            twitter,
153            open_graph,
154            sitemap,
155            base_page: _,
156        } = self;
157        icon.is_empty()
158            && apple.is_empty()
159            && twitter.is_empty()
160            && open_graph.is_empty()
161            && sitemap.is_none()
162    }
163}
164
165/// Metadata files that can be placed in the root of the app directory.
166#[turbo_tasks::value]
167#[derive(Default, Clone, Debug)]
168pub struct GlobalMetadata {
169    pub favicon: Option<MetadataItem>,
170    pub robots: Option<MetadataItem>,
171    pub manifest: Option<MetadataItem>,
172}
173
174impl GlobalMetadata {
175    pub fn is_empty(&self) -> bool {
176        let GlobalMetadata {
177            favicon,
178            robots,
179            manifest,
180        } = self;
181        favicon.is_none() && robots.is_none() && manifest.is_none()
182    }
183}
184
185#[turbo_tasks::value]
186#[derive(Debug)]
187pub struct DirectoryTree {
188    /// key is e.g. "dashboard", "(dashboard)", "@slot"
189    pub subdirectories: BTreeMap<RcStr, ResolvedVc<DirectoryTree>>,
190    pub modules: AppDirModules,
191}
192
193#[turbo_tasks::value]
194#[derive(Clone, Debug)]
195struct PlainDirectoryTree {
196    /// key is e.g. "dashboard", "(dashboard)", "@slot"
197    pub subdirectories: BTreeMap<RcStr, PlainDirectoryTree>,
198    pub modules: AppDirModules,
199    /// Whether this subtree contains a page or default, including inside parallel routes.
200    pub contains_page_or_default: bool,
201    /// Flattened URL tree with route groups and parallel routes transparent.
202    pub url_tree: UrlSegmentTree,
203}
204
205/// A tree representing the URL segment structure, with route groups and parallel
206/// routes flattened out. This provides a unified view of all segments at each URL
207/// level, regardless of which route group they're defined in.
208///
209/// For example, given this directory structure:
210///
211/// ```text
212/// app/
213/// ├── (group1)/
214/// │   └── products/
215/// │       └── sale/
216/// └── (group2)/
217///     └── products/
218///         └── [id]/
219/// ```
220///
221/// The UrlSegmentTree would be:
222///
223/// ```text
224/// (root)
225/// └── products/
226///     ├── sale/
227///     └── [id]/
228/// ```
229///
230/// This makes it easy to find all siblings at a given URL level.
231#[derive(Clone, Debug, Default, PartialEq, Eq, NonLocalValue, Encode, Decode)]
232struct UrlSegmentTree {
233    pub children: BTreeMap<RcStr, UrlSegmentTree>,
234}
235
236impl UrlSegmentTree {
237    fn static_children(&self) -> Vec<RcStr> {
238        self.children
239            .keys()
240            .filter(|name| !is_dynamic_segment(name))
241            .cloned()
242            .collect()
243    }
244
245    fn get_child(&self, segment: &str) -> Option<&UrlSegmentTree> {
246        self.children.get(segment)
247    }
248}
249
250fn build_url_segment_tree_from_subdirs(
251    subdirs: &BTreeMap<RcStr, PlainDirectoryTree>,
252) -> UrlSegmentTree {
253    let mut result = UrlSegmentTree::default();
254    build_url_segment_tree_recursive(subdirs, &mut result);
255    result
256}
257
258/// Recursively builds the URL segment tree by accumulating children at each
259/// URL level. Segments from different route groups that share the same URL path
260/// are merged together.
261///
262/// Example: `(group1)/products/sale/` and `(group2)/products/[id]/` both
263/// contribute to a single `products/` node containing both `sale/` and `[id]/`.
264fn build_url_segment_tree_recursive(
265    subdirs: &BTreeMap<RcStr, PlainDirectoryTree>,
266    result: &mut UrlSegmentTree,
267) {
268    for (name, subtree) in subdirs {
269        if is_url_transparent_segment(name) {
270            // Transparent segments (route groups, parallel routes) don't create
271            // a new URL level. Recurse with the same `result` so their children
272            // are accumulated at the current level.
273            build_url_segment_tree_recursive(&subtree.subdirectories, result);
274        } else {
275            // Non-transparent segments create a new URL level. Get or create a
276            // child node for this segment, then recurse to accumulate its children.
277            // Using `or_default()` ensures that if this segment was already added
278            // from a different route group, we merge into it rather than replace.
279            let child = result.children.entry(name.clone()).or_default();
280            build_url_segment_tree_recursive(&subtree.subdirectories, child);
281        }
282    }
283}
284
285#[turbo_tasks::value_impl]
286impl DirectoryTree {
287    #[turbo_tasks::function]
288    pub async fn into_plain(&self) -> Result<Vc<PlainDirectoryTree>> {
289        let mut subdirectories = BTreeMap::new();
290
291        for (name, subdirectory) in &self.subdirectories {
292            subdirectories.insert(name.clone(), subdirectory.into_plain().owned().await?);
293        }
294
295        let contains_page_or_default = self.modules.page.is_some()
296            || self.modules.default.is_some()
297            || subdirectories
298                .values()
299                .any(|subdirectory| subdirectory.contains_page_or_default);
300        let url_tree = build_url_segment_tree_from_subdirs(&subdirectories);
301
302        Ok(PlainDirectoryTree {
303            subdirectories,
304            modules: self.modules.clone(),
305            contains_page_or_default,
306            url_tree,
307        }
308        .cell())
309    }
310}
311
312#[turbo_tasks::value(transparent)]
313pub struct OptionAppDir(Option<FileSystemPath>);
314
315/// Finds and returns the [DirectoryTree] of the app directory if existing.
316#[turbo_tasks::function]
317pub async fn find_app_dir(project_path: FileSystemPath) -> Result<Vc<OptionAppDir>> {
318    let app = project_path.join("app")?;
319    let src_app = project_path.join("src/app")?;
320    let app_dir = if *app.get_type().await? == FileSystemEntryType::Directory {
321        app
322    } else if *src_app.get_type().await? == FileSystemEntryType::Directory {
323        src_app
324    } else {
325        return Ok(Vc::cell(None));
326    };
327
328    Ok(Vc::cell(Some(app_dir)))
329}
330
331#[turbo_tasks::function]
332async fn get_directory_tree(
333    dir: FileSystemPath,
334    page_extensions: Vc<Vec<RcStr>>,
335) -> Result<Vc<DirectoryTree>> {
336    let span = tracing::info_span!(
337        "read app directory tree",
338        name = display(dir.to_string_ref().await?)
339    );
340    get_directory_tree_internal(dir, page_extensions)
341        .instrument(span)
342        .await
343}
344
345async fn get_directory_tree_internal(
346    dir: FileSystemPath,
347    page_extensions: Vc<Vec<RcStr>>,
348) -> Result<Vc<DirectoryTree>> {
349    let DirectoryContent::Entries(entries) = &*dir.read_dir().await? else {
350        // the file watcher might invalidate things in the wrong order,
351        // and we have to account for the eventual consistency of turbo-tasks
352        // so we just return an empty tree here.
353        return Ok(DirectoryTree {
354            subdirectories: Default::default(),
355            modules: AppDirModules::default(),
356        }
357        .cell());
358    };
359    let page_extensions_value = page_extensions.await?;
360
361    let mut subdirectories = BTreeMap::new();
362    let mut modules = AppDirModules::default();
363
364    let mut metadata_icon = Vec::new();
365    let mut metadata_apple = Vec::new();
366    let mut metadata_open_graph = Vec::new();
367    let mut metadata_twitter = Vec::new();
368
369    for (basename, entry) in entries {
370        let entry = entry.clone().resolve_symlink().await?;
371        match entry {
372            DirectoryEntry::File(file) => {
373                // Do not process .d.ts files as routes
374                if basename.ends_with(".d.ts") {
375                    continue;
376                }
377                if let Some((stem, ext)) = basename.split_once('.')
378                    && page_extensions_value.iter().any(|e| e == ext)
379                {
380                    match stem {
381                        "page" => modules.page = Some(file.clone()),
382                        "layout" => modules.layout = Some(file.clone()),
383                        "error" => modules.error = Some(file.clone()),
384                        "global-error" => modules.global_error = Some(file.clone()),
385                        "global-not-found" => modules.global_not_found = Some(file.clone()),
386                        "loading" => modules.loading = Some(file.clone()),
387                        "template" => modules.template = Some(file.clone()),
388                        "forbidden" => modules.forbidden = Some(file.clone()),
389                        "unauthorized" => modules.unauthorized = Some(file.clone()),
390                        "not-found" => modules.not_found = Some(file.clone()),
391                        "default" => modules.default = Some(file.clone()),
392                        "route" => modules.route = Some(file.clone()),
393                        _ => {}
394                    }
395                }
396
397                let Some(MetadataFileMatch {
398                    metadata_type,
399                    number,
400                    dynamic,
401                }) = match_local_metadata_file(basename.as_str(), &page_extensions_value)
402                else {
403                    continue;
404                };
405
406                let entry = match metadata_type {
407                    "icon" => &mut metadata_icon,
408                    "apple-icon" => &mut metadata_apple,
409                    "twitter-image" => &mut metadata_twitter,
410                    "opengraph-image" => &mut metadata_open_graph,
411                    "sitemap" => {
412                        if dynamic {
413                            modules.metadata.sitemap = Some(MetadataItem::Dynamic { path: file });
414                        } else {
415                            modules.metadata.sitemap = Some(MetadataItem::Static { path: file });
416                        }
417                        continue;
418                    }
419                    _ => continue,
420                };
421
422                if dynamic {
423                    entry.push((number, MetadataWithAltItem::Dynamic { path: file }));
424                    continue;
425                }
426
427                let file_name = file.file_name();
428                let basename = file_name
429                    .rsplit_once('.')
430                    .map_or(file_name, |(basename, _)| basename);
431                let alt_path = file.parent().join(&format!("{basename}.alt.txt"))?;
432                let alt_path = matches!(&*alt_path.get_type().await?, FileSystemEntryType::File)
433                    .then_some(alt_path);
434
435                entry.push((
436                    number,
437                    MetadataWithAltItem::Static {
438                        path: file,
439                        alt_path,
440                    },
441                ));
442            }
443            DirectoryEntry::Directory(dir)
444                // appDir ignores paths starting with an underscore
445                if !basename.starts_with('_') => {
446                    let result = get_directory_tree(dir.clone(), page_extensions)
447                        .to_resolved()
448                        .await?;
449                    subdirectories.insert(basename.clone(), result);
450                }
451            // TODO(WEB-952) handle symlinks in app dir
452            _ => {}
453        }
454    }
455
456    fn sort<T>(mut list: Vec<(Option<u32>, T)>) -> Vec<T> {
457        list.sort_by_key(|(num, _)| *num);
458        list.into_iter().map(|(_, item)| item).collect()
459    }
460
461    modules.metadata.icon = sort(metadata_icon);
462    modules.metadata.apple = sort(metadata_apple);
463    modules.metadata.twitter = sort(metadata_twitter);
464    modules.metadata.open_graph = sort(metadata_open_graph);
465
466    Ok(DirectoryTree {
467        subdirectories,
468        modules,
469    }
470    .cell())
471}
472
473#[turbo_tasks::value]
474#[derive(Debug, Clone)]
475pub struct AppPageLoaderTree {
476    pub page: AppPage,
477    pub segment: RcStr,
478    #[bincode(with = "turbo_bincode::indexmap")]
479    pub parallel_routes: FxIndexMap<RcStr, AppPageLoaderTree>,
480    pub modules: AppDirModules,
481    pub global_metadata: ResolvedVc<GlobalMetadata>,
482    /// For dynamic segments, contains the list of static sibling segments that
483    /// exist at the same URL path level. Used by the client router to determine
484    /// if a prefetch can be reused.
485    pub static_siblings: Vec<RcStr>,
486}
487
488impl AppPageLoaderTree {
489    fn collect_page_files(&self, pages: &mut FxIndexSet<FileSystemPath>) {
490        if let Some(page) = &self.modules.page {
491            pages.insert(page.clone());
492        }
493
494        for tree in self.parallel_routes.values() {
495            tree.collect_page_files(pages);
496        }
497    }
498
499    /// Returns true if there's a page match in this loader tree.
500    pub fn has_page(&self) -> bool {
501        if &*self.segment == "__PAGE__" {
502            return true;
503        }
504
505        for (_, tree) in &self.parallel_routes {
506            if tree.has_page() {
507                return true;
508            }
509        }
510
511        false
512    }
513
514    /// Returns whether the only match in this tree is for a catch-all
515    /// route.
516    pub fn has_only_catchall(&self) -> bool {
517        if &*self.segment == "__PAGE__" && !self.page.is_catchall() {
518            return false;
519        }
520
521        for (_, tree) in &self.parallel_routes {
522            if !tree.has_only_catchall() {
523                return false;
524            }
525        }
526
527        true
528    }
529
530    fn contains_catchall_page(&self) -> bool {
531        (&*self.segment == "__PAGE__" && self.page.is_catchall())
532            || self
533                .parallel_routes
534                .values()
535                .any(AppPageLoaderTree::contains_catchall_page)
536    }
537
538    fn is_builtin_not_found_default(&self, builtin_default: &FileSystemPath) -> bool {
539        &*self.segment == "__DEFAULT__"
540            && self.modules.default.as_ref().is_some_and(|default| {
541                default.fs == builtin_default.fs && default.path == builtin_default.path
542            })
543    }
544
545    /// Returns true when a slot declared by its owning layout can only use Next.js' built-in
546    /// not-found default. Structural router-state branches can contain the same default without
547    /// being renderable slots, so they do not make the matcher incomplete.
548    fn contains_declared_builtin_not_found_default(
549        &self,
550        builtin_default: &FileSystemPath,
551        declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
552        parent_layout: Option<&FileSystemPath>,
553    ) -> bool {
554        let owner_layout = self.modules.layout.as_ref().or(parent_layout);
555
556        self.parallel_routes.iter().any(|(slot, tree)| {
557            let is_declared_default = tree.is_builtin_not_found_default(builtin_default)
558                && owner_layout.is_some_and(|layout| {
559                    declared_slots
560                        .get(layout)
561                        .is_some_and(|slots| slots.contains(slot))
562                });
563
564            is_declared_default
565                || tree.contains_declared_builtin_not_found_default(
566                    builtin_default,
567                    declared_slots,
568                    owner_layout,
569                )
570        })
571    }
572
573    fn collect_builtin_not_found_defaults(
574        &self,
575        builtin_default: &FileSystemPath,
576        declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
577        parent_layout: Option<&FileSystemPath>,
578        missing_slots: &mut FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
579    ) {
580        let owner_layout = self.modules.layout.as_ref().or(parent_layout);
581
582        for (slot, tree) in &self.parallel_routes {
583            if tree.is_builtin_not_found_default(builtin_default) {
584                if let Some(owner_layout) = owner_layout
585                    && declared_slots
586                        .get(owner_layout)
587                        .is_some_and(|slots| slots.contains(slot))
588                {
589                    missing_slots
590                        .entry(owner_layout.clone())
591                        .or_default()
592                        .insert(slot.clone());
593                }
594            } else {
595                tree.collect_builtin_not_found_defaults(
596                    builtin_default,
597                    declared_slots,
598                    owner_layout,
599                    missing_slots,
600                );
601            }
602        }
603    }
604
605    /// Returns true when one slot matches through a catch-all while a different slot at the same
606    /// level can only render Next.js' built-in not-found default.
607    fn has_unmatched_parallel_route(
608        &self,
609        builtin_default: &FileSystemPath,
610        declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
611        parent_layout: Option<&FileSystemPath>,
612    ) -> bool {
613        let owner_layout = self.modules.layout.as_ref().or(parent_layout);
614        let slots_at_level = owner_layout.and_then(|layout| declared_slots.get(layout));
615
616        self.parallel_routes.iter().any(|(catchall_key, tree)| {
617            slots_at_level.is_some_and(|slots| slots.contains(catchall_key))
618                && tree.contains_catchall_page()
619                && self.parallel_routes.iter().any(|(default_key, tree)| {
620                    default_key != catchall_key
621                        && slots_at_level.is_some_and(|slots| slots.contains(default_key))
622                        && tree.is_builtin_not_found_default(builtin_default)
623                })
624        }) || self.parallel_routes.values().any(|tree| {
625            tree.has_unmatched_parallel_route(builtin_default, declared_slots, owner_layout)
626        })
627    }
628
629    /// Returns true if this loader tree contains an intercepting route match.
630    pub fn is_intercepting(&self) -> bool {
631        if self.page.is_intercepting() && self.has_page() {
632            return true;
633        }
634
635        for (_, tree) in &self.parallel_routes {
636            if tree.is_intercepting() {
637                return true;
638            }
639        }
640
641        false
642    }
643
644    /// Returns the specificity of the page (i.e. the number of segments
645    /// affecting the path)
646    pub fn get_specificity(&self) -> usize {
647        if &*self.segment == "__PAGE__" {
648            return AppPath::from(self.page.clone()).len();
649        }
650
651        let mut specificity = 0;
652
653        for (_, tree) in &self.parallel_routes {
654            specificity = specificity.max(tree.get_specificity());
655        }
656
657        specificity
658    }
659}
660
661#[turbo_tasks::value(transparent)]
662#[derive(Default)]
663pub struct RootParamVecOption(Option<Vec<RcStr>>);
664
665#[turbo_tasks::value_impl]
666impl ValueDefault for RootParamVecOption {
667    #[turbo_tasks::function]
668    fn value_default() -> Vc<Self> {
669        Vc::cell(Default::default())
670    }
671}
672
673#[turbo_tasks::value(transparent)]
674pub struct FileSystemPathVec(Vec<FileSystemPath>);
675
676#[turbo_tasks::value_impl]
677impl ValueDefault for FileSystemPathVec {
678    #[turbo_tasks::function]
679    fn value_default() -> Vc<Self> {
680        Vc::cell(Vec::new())
681    }
682}
683
684#[turbo_tasks::task_input]
685#[derive(Clone, PartialEq, Eq, Hash, ValueDebugFormat, Debug, Encode, Decode)]
686pub enum Entrypoint {
687    AppPage {
688        pages: Vec<AppPage>,
689        loader_tree: ResolvedVc<AppPageLoaderTree>,
690        participating_page_files: Vec<FileSystemPath>,
691        root_params: ResolvedVc<RootParamVecOption>,
692    },
693    AppRoute {
694        page: AppPage,
695        path: FileSystemPath,
696        root_layouts: ResolvedVc<FileSystemPathVec>,
697        root_params: ResolvedVc<RootParamVecOption>,
698    },
699    AppMetadata {
700        page: AppPage,
701        metadata: MetadataItem,
702        root_params: ResolvedVc<RootParamVecOption>,
703    },
704}
705
706impl Entrypoint {
707    pub fn page(&self) -> &AppPage {
708        match self {
709            Entrypoint::AppPage { pages, .. } => pages.first().unwrap(),
710            Entrypoint::AppRoute { page, .. } => page,
711            Entrypoint::AppMetadata { page, .. } => page,
712        }
713    }
714    pub fn root_params(&self) -> ResolvedVc<RootParamVecOption> {
715        match self {
716            Entrypoint::AppPage { root_params, .. } => *root_params,
717            Entrypoint::AppRoute { root_params, .. } => *root_params,
718            Entrypoint::AppMetadata { root_params, .. } => *root_params,
719        }
720    }
721}
722
723#[turbo_tasks::value(transparent)]
724pub struct Entrypoints(
725    #[bincode(with = "turbo_bincode::indexmap")] FxIndexMap<AppPath, Entrypoint>,
726);
727
728fn is_parallel_route(name: &str) -> bool {
729    name.starts_with('@')
730}
731
732fn is_group_route(name: &str) -> bool {
733    name.starts_with('(') && name.ends_with(')')
734}
735
736/// Returns true if this segment is "transparent" from a URL perspective.
737/// Route groups like `(marketing)` and parallel routes like `@modal` exist in
738/// the file system but don't contribute to the URL path.
739fn is_url_transparent_segment(name: &str) -> bool {
740    is_group_route(name) || is_parallel_route(name)
741}
742
743fn is_dynamic_segment(name: &str) -> bool {
744    name.starts_with('[') && name.ends_with(']')
745}
746
747fn match_parallel_route(name: &str) -> Option<&str> {
748    name.strip_prefix('@')
749}
750
751fn conflict_issue(
752    app_dir: FileSystemPath,
753    e: &'_ OccupiedEntry<'_, AppPath, Entrypoint>,
754    a: &str,
755    b: &str,
756    value_a: &AppPage,
757    value_b: &AppPage,
758) {
759    let item_names = if a == b {
760        format!("{a}s")
761    } else {
762        format!("{a} and {b}")
763    };
764
765    DirectoryTreeIssue {
766        app_dir,
767        message: StyledString::Text(
768            format!(
769                "Conflicting {} at {}: {a} at {value_a} and {b} at {value_b}",
770                item_names,
771                e.key(),
772            )
773            .into(),
774        )
775        .resolved_cell(),
776        severity: IssueSeverity::Error,
777    }
778    .resolved_cell()
779    .emit();
780}
781
782fn add_app_page(
783    app_dir: FileSystemPath,
784    result: &mut FxIndexMap<AppPath, Entrypoint>,
785    page: AppPage,
786    loader_tree: ResolvedVc<AppPageLoaderTree>,
787    participating_page_files: Vec<FileSystemPath>,
788    root_params: ResolvedVc<RootParamVecOption>,
789) {
790    let mut e = match result.entry(page.clone().into()) {
791        Entry::Occupied(e) => e,
792        Entry::Vacant(e) => {
793            e.insert(Entrypoint::AppPage {
794                pages: vec![page],
795                loader_tree,
796                participating_page_files,
797                root_params,
798            });
799            return;
800        }
801    };
802
803    let conflict = |existing_name: &str, existing_page: &AppPage| {
804        conflict_issue(app_dir, &e, "page", existing_name, &page, existing_page);
805    };
806
807    let value = e.get();
808    match value {
809        Entrypoint::AppPage {
810            pages: existing_pages,
811            loader_tree: existing_loader_tree,
812            ..
813        } => {
814            // loader trees should always match for the same path as they are generated by a
815            // turbo tasks function
816            if *existing_loader_tree != loader_tree {
817                conflict("page", existing_pages.first().unwrap());
818            }
819
820            let Entrypoint::AppPage {
821                pages: stored_pages,
822                participating_page_files: stored_page_files,
823                ..
824            } = e.get_mut()
825            else {
826                unreachable!("Entrypoint::AppPage was already matched");
827            };
828
829            stored_pages.push(page);
830            stored_pages.sort();
831            for page_file in participating_page_files {
832                if !stored_page_files.contains(&page_file) {
833                    stored_page_files.push(page_file);
834                }
835            }
836        }
837        Entrypoint::AppRoute {
838            page: existing_page,
839            ..
840        } => {
841            conflict("route", existing_page);
842        }
843        Entrypoint::AppMetadata {
844            page: existing_page,
845            ..
846        } => {
847            conflict("metadata", existing_page);
848        }
849    }
850}
851
852fn add_app_route(
853    app_dir: FileSystemPath,
854    result: &mut FxIndexMap<AppPath, Entrypoint>,
855    page: AppPage,
856    path: FileSystemPath,
857    root_layouts: ResolvedVc<FileSystemPathVec>,
858    root_params: ResolvedVc<RootParamVecOption>,
859) {
860    let e = match result.entry(page.clone().into()) {
861        Entry::Occupied(e) => e,
862        Entry::Vacant(e) => {
863            e.insert(Entrypoint::AppRoute {
864                page,
865                path,
866                root_layouts,
867                root_params,
868            });
869            return;
870        }
871    };
872
873    let conflict = |existing_name: &str, existing_page: &AppPage| {
874        conflict_issue(app_dir, &e, "route", existing_name, &page, existing_page);
875    };
876
877    let value = e.get();
878    match value {
879        Entrypoint::AppPage { pages, .. } => {
880            conflict("page", pages.first().unwrap());
881        }
882        Entrypoint::AppRoute {
883            page: existing_page,
884            ..
885        } => {
886            conflict("route", existing_page);
887        }
888        Entrypoint::AppMetadata {
889            page: existing_page,
890            ..
891        } => {
892            conflict("metadata", existing_page);
893        }
894    }
895}
896
897fn add_app_metadata_route(
898    app_dir: FileSystemPath,
899    result: &mut FxIndexMap<AppPath, Entrypoint>,
900    page: AppPage,
901    metadata: MetadataItem,
902    root_params: ResolvedVc<RootParamVecOption>,
903) {
904    let e = match result.entry(page.clone().into()) {
905        Entry::Occupied(e) => e,
906        Entry::Vacant(e) => {
907            e.insert(Entrypoint::AppMetadata {
908                page,
909                metadata,
910                root_params,
911            });
912            return;
913        }
914    };
915
916    let conflict = |existing_name: &str, existing_page: &AppPage| {
917        conflict_issue(app_dir, &e, "metadata", existing_name, &page, existing_page);
918    };
919
920    let value = e.get();
921    match value {
922        Entrypoint::AppPage { pages, .. } => {
923            conflict("page", pages.first().unwrap());
924        }
925        Entrypoint::AppRoute {
926            page: existing_page,
927            ..
928        } => {
929            conflict("route", existing_page);
930        }
931        Entrypoint::AppMetadata {
932            page: existing_page,
933            ..
934        } => {
935            conflict("metadata", existing_page);
936        }
937    }
938}
939
940#[turbo_tasks::function]
941pub fn get_entrypoints(
942    app_dir: FileSystemPath,
943    page_extensions: Vc<Vec<RcStr>>,
944    is_global_not_found_enabled: Vc<bool>,
945    explicit_parallel_route_children: Vc<bool>,
946    strict_route_matching: Vc<bool>,
947    next_mode: Vc<NextMode>,
948) -> Vc<Entrypoints> {
949    directory_tree_to_entrypoints(
950        app_dir.clone(),
951        get_directory_tree(app_dir.clone(), page_extensions),
952        get_global_metadata(app_dir, page_extensions),
953        is_global_not_found_enabled,
954        explicit_parallel_route_children,
955        strict_route_matching,
956        next_mode,
957        Default::default(),
958        Default::default(),
959    )
960}
961
962#[turbo_tasks::value(transparent)]
963pub struct CollectedRootParams(#[bincode(with = "turbo_bincode::indexset")] FxIndexSet<RcStr>);
964
965#[turbo_tasks::function]
966pub async fn collect_root_params(
967    entrypoints: ResolvedVc<Entrypoints>,
968) -> Result<Vc<CollectedRootParams>> {
969    let mut collected_root_params = FxIndexSet::<RcStr>::default();
970    for (_, entrypoint) in entrypoints.await?.iter() {
971        if let Some(ref root_params) = *entrypoint.root_params().await? {
972            collected_root_params.extend(root_params.iter().cloned());
973        }
974    }
975    Ok(Vc::cell(collected_root_params))
976}
977
978#[turbo_tasks::function]
979async fn directory_tree_to_entrypoints(
980    app_dir: FileSystemPath,
981    directory_tree: Vc<DirectoryTree>,
982    global_metadata: Vc<GlobalMetadata>,
983    is_global_not_found_enabled: Vc<bool>,
984    explicit_parallel_route_children: Vc<bool>,
985    strict_route_matching: Vc<bool>,
986    next_mode: Vc<NextMode>,
987    root_layouts: Vc<FileSystemPathVec>,
988    root_params: Vc<RootParamVecOption>,
989) -> Result<Vc<Entrypoints>> {
990    let entrypoints = directory_tree_to_entrypoints_internal(
991        app_dir.clone(),
992        global_metadata,
993        is_global_not_found_enabled,
994        explicit_parallel_route_children,
995        strict_route_matching,
996        next_mode,
997        rcstr!(""),
998        directory_tree,
999        AppPage::new(),
1000        root_layouts,
1001        root_params,
1002    );
1003
1004    if !*strict_route_matching.await? {
1005        return Ok(entrypoints);
1006    }
1007
1008    let builtin_default = get_next_package(app_dir.clone())
1009        .await?
1010        .join("dist/client/components/builtin/default.js")?;
1011    let entrypoints_ref = entrypoints.await?;
1012    let plain_tree = directory_tree.into_plain().await?;
1013    let mut declared_slots = FxIndexMap::default();
1014    collect_declared_parallel_route_slots(&plain_tree, &mut declared_slots);
1015    let mut candidate_entrypoints = FxIndexMap::default();
1016
1017    // Loader trees built while walking a subtree may still contain temporary synthesized
1018    // defaults that disappear when sibling pages are combined. Inspect only the finalized root
1019    // entrypoints so complete routes are never discarded based on an intermediate tree. This
1020    // layer retains incomplete static matchers long enough to report their exact slot topology;
1021    // incomplete catch-all matchers preserve the pruning behavior from the lower layer.
1022    for (app_path, entrypoint) in entrypoints_ref.iter() {
1023        let is_incomplete = match entrypoint {
1024            Entrypoint::AppPage { loader_tree, .. } => loader_tree
1025                .await?
1026                .has_unmatched_parallel_route(&builtin_default, &declared_slots, None),
1027            _ => false,
1028        };
1029
1030        if !is_incomplete {
1031            candidate_entrypoints.insert(app_path.clone(), entrypoint.clone());
1032        }
1033    }
1034
1035    let mut incompatible_parallel_route_slots = Vec::new();
1036    let mut retained_entrypoints = FxIndexMap::default();
1037
1038    // Report incomplete static matchers from their finalized loader trees, but still prune them
1039    // from the matcher set. Interception routes intentionally use synthetic retain markers and
1040    // therefore follow different reporting rules.
1041    for (app_path, entrypoint) in &candidate_entrypoints {
1042        let Entrypoint::AppPage { loader_tree, .. } = entrypoint else {
1043            retained_entrypoints.insert(app_path.clone(), entrypoint.clone());
1044            continue;
1045        };
1046        let loader_tree = loader_tree.await?;
1047        if !loader_tree.contains_declared_builtin_not_found_default(
1048            &builtin_default,
1049            &declared_slots,
1050            None,
1051        ) {
1052            retained_entrypoints.insert(app_path.clone(), entrypoint.clone());
1053            continue;
1054        }
1055        if app_path.intercepted_path().is_some() {
1056            continue;
1057        }
1058
1059        let mut missing_slots = FxIndexMap::default();
1060        loader_tree.collect_builtin_not_found_defaults(
1061            &builtin_default,
1062            &declared_slots,
1063            None,
1064            &mut missing_slots,
1065        );
1066        if missing_slots.is_empty() {
1067            bail!(
1068                "Invariant: strict route matching retained the incomplete route matcher \
1069                 `{app_path}`"
1070            );
1071        }
1072        incompatible_parallel_route_slots.extend(
1073            missing_slots
1074                .into_iter()
1075                .map(|(layout, slots)| (layout, app_path.clone(), slots.into_iter().collect())),
1076        );
1077    }
1078
1079    if !incompatible_parallel_route_slots.is_empty() {
1080        IncompatibleParallelRouteSlotsIssue {
1081            app_dir: app_dir.clone(),
1082            routes: incompatible_parallel_route_slots,
1083        }
1084        .resolved_cell()
1085        .emit();
1086    }
1087
1088    // This assertion is intentionally separate from the filtering condition above. It guards
1089    // future changes to entrypoint construction or pruning that might retain an incomplete tree.
1090    for (app_path, entrypoint) in &retained_entrypoints {
1091        let Entrypoint::AppPage { loader_tree, .. } = entrypoint else {
1092            continue;
1093        };
1094        if !app_path.contains_interception()
1095            && loader_tree
1096                .await?
1097                .contains_declared_builtin_not_found_default(
1098                    &builtin_default,
1099                    &declared_slots,
1100                    None,
1101                )
1102        {
1103            bail!(
1104                "Invariant: strict route matching retained the incomplete route matcher \
1105                 `{app_path}`"
1106            );
1107        }
1108    }
1109
1110    let ordinary_routes = retained_entrypoints
1111        .iter()
1112        .filter_map(|(route, entrypoint)| {
1113            (!route.contains_interception() && matches!(entrypoint, Entrypoint::AppPage { .. }))
1114                .then_some(route)
1115        })
1116        .collect::<Vec<_>>();
1117    let missing_canonical_interception_routes = retained_entrypoints
1118        .iter()
1119        .filter_map(|(interception_route, entrypoint)| {
1120            let Entrypoint::AppPage {
1121                participating_page_files,
1122                ..
1123            } = entrypoint
1124            else {
1125                return None;
1126            };
1127            let canonical_route = interception_route.intercepted_path()?;
1128            if canonical_route.is_route_pattern_covered_by(ordinary_routes.iter().copied()) {
1129                return None;
1130            }
1131
1132            Some((
1133                interception_route.clone(),
1134                canonical_route,
1135                participating_page_files.first()?.clone(),
1136            ))
1137        })
1138        .collect::<Vec<_>>();
1139    if !missing_canonical_interception_routes.is_empty() {
1140        MissingCanonicalInterceptionRoutesIssue {
1141            routes: missing_canonical_interception_routes,
1142        }
1143        .resolved_cell()
1144        .emit();
1145    }
1146
1147    let mut authored_pages = FxIndexSet::default();
1148    collect_authored_page_files(&plain_tree, &mut authored_pages);
1149
1150    let mut matched_pages = FxIndexSet::default();
1151    for entrypoint in retained_entrypoints.values() {
1152        if let Entrypoint::AppPage {
1153            participating_page_files,
1154            ..
1155        } = entrypoint
1156        {
1157            matched_pages.extend(participating_page_files.iter().cloned());
1158        }
1159    }
1160
1161    let unmatched_pages = authored_pages
1162        .into_iter()
1163        .filter(|page| !matched_pages.contains(page))
1164        .collect::<Vec<_>>();
1165    if !unmatched_pages.is_empty() {
1166        UnmatchedAppPagesIssue {
1167            app_dir,
1168            pages: unmatched_pages,
1169        }
1170        .resolved_cell()
1171        .emit();
1172    }
1173    Ok(Vc::cell(retained_entrypoints))
1174}
1175
1176#[turbo_tasks::value]
1177struct MissingCanonicalInterceptionRoutesIssue {
1178    routes: Vec<(AppPath, AppPath, FileSystemPath)>,
1179}
1180
1181#[turbo_tasks::value]
1182struct IncompatibleParallelRouteSlotsIssue {
1183    app_dir: FileSystemPath,
1184    routes: Vec<(FileSystemPath, AppPath, Vec<RcStr>)>,
1185}
1186
1187#[async_trait]
1188#[turbo_tasks::value_impl]
1189impl Issue for IncompatibleParallelRouteSlotsIssue {
1190    async fn file_path(&self) -> Result<FileSystemPath> {
1191        Ok(self.routes[0].0.clone())
1192    }
1193
1194    fn stage(&self) -> IssueStage {
1195        IssueStage::AppStructure
1196    }
1197
1198    fn severity(&self) -> IssueSeverity {
1199        IssueSeverity::Error
1200    }
1201
1202    async fn title(&self) -> Result<StyledString> {
1203        Ok(StyledString::Text(rcstr!(
1204            "Parallel route slots cannot render the same URLs"
1205        )))
1206    }
1207
1208    async fn description(&self) -> Result<Option<StyledString>> {
1209        let mut routes_by_layout = FxIndexMap::<FileSystemPath, Vec<_>>::default();
1210        for (layout, route, missing_slots) in &self.routes {
1211            routes_by_layout
1212                .entry(layout.clone())
1213                .or_default()
1214                .push((route.clone(), missing_slots.clone()));
1215        }
1216
1217        let mut layouts = routes_by_layout
1218            .into_iter()
1219            .map(|(layout, mut routes)| {
1220                routes.sort_by_cached_key(|(route, _)| route.to_string());
1221                let layout_path = self
1222                    .app_dir
1223                    .get_path_to(&layout)
1224                    .expect("parallel route layout should be within the app directory")
1225                    .to_string();
1226                let routes = routes
1227                    .into_iter()
1228                    .map(|(route, missing_slots)| {
1229                        let missing_slots = missing_slots
1230                            .iter()
1231                            .map(|slot| {
1232                                if &**slot == "children" {
1233                                    slot.to_string()
1234                                } else {
1235                                    format!("@{slot}")
1236                                }
1237                            })
1238                            .collect::<Vec<_>>()
1239                            .join(", ");
1240                        format!(
1241                            "- {route} is missing a matching page or default.tsx in \
1242                             {missing_slots}"
1243                        )
1244                    })
1245                    .collect::<Vec<_>>()
1246                    .join("\n");
1247                (layout_path, routes)
1248            })
1249            .collect::<Vec<_>>();
1250        layouts.sort_by(|a, b| a.0.cmp(&b.0));
1251        let layouts = layouts
1252            .into_iter()
1253            .map(|(layout_path, routes)| format!("app/{layout_path}\n{routes}"))
1254            .collect::<Vec<_>>()
1255            .join("\n\n");
1256
1257        Ok(Some(StyledString::Text(
1258            format!(
1259                "The following layouts have parallel route slots that cannot render the same \
1260                 URLs:\n{layouts}\n\nEvery URL matched by one slot must have a matching page or \
1261                 default.tsx in every sibling slot."
1262            )
1263            .into(),
1264        )))
1265    }
1266}
1267
1268#[async_trait]
1269#[turbo_tasks::value_impl]
1270impl Issue for MissingCanonicalInterceptionRoutesIssue {
1271    async fn file_path(&self) -> Result<FileSystemPath> {
1272        Ok(self.routes[0].2.clone())
1273    }
1274
1275    fn stage(&self) -> IssueStage {
1276        IssueStage::AppStructure
1277    }
1278
1279    fn severity(&self) -> IssueSeverity {
1280        IssueSeverity::Error
1281    }
1282
1283    async fn title(&self) -> Result<StyledString> {
1284        Ok(StyledString::Text(rcstr!(
1285            "Interception routes must have a canonical route"
1286        )))
1287    }
1288
1289    async fn description(&self) -> Result<Option<StyledString>> {
1290        let routes = self
1291            .routes
1292            .iter()
1293            .map(|(interception_route, canonical_route, _)| {
1294                format!("- {interception_route} (expected {canonical_route})")
1295            })
1296            .collect::<Vec<_>>()
1297            .join("\n");
1298        Ok(Some(StyledString::Text(
1299            format!(
1300                "The following interception routes do not have a canonical \
1301                 route:\n{routes}\n\nEvery interception route must have a matching \
1302                 non-interception route so the URL can be loaded directly or refreshed."
1303            )
1304            .into(),
1305        )))
1306    }
1307}
1308
1309fn collect_authored_page_files(
1310    directory_tree: &PlainDirectoryTree,
1311    pages: &mut FxIndexSet<FileSystemPath>,
1312) {
1313    if let Some(page) = &directory_tree.modules.page {
1314        pages.insert(page.clone());
1315    }
1316
1317    for subdirectory in directory_tree.subdirectories.values() {
1318        collect_authored_page_files(subdirectory, pages);
1319    }
1320}
1321
1322#[turbo_tasks::value]
1323struct UnmatchedAppPagesIssue {
1324    app_dir: FileSystemPath,
1325    pages: Vec<FileSystemPath>,
1326}
1327
1328#[async_trait]
1329#[turbo_tasks::value_impl]
1330impl Issue for UnmatchedAppPagesIssue {
1331    async fn file_path(&self) -> Result<FileSystemPath> {
1332        Ok(self.pages[0].clone())
1333    }
1334
1335    fn stage(&self) -> IssueStage {
1336        IssueStage::AppStructure
1337    }
1338
1339    fn severity(&self) -> IssueSeverity {
1340        IssueSeverity::Error
1341    }
1342
1343    async fn title(&self) -> Result<StyledString> {
1344        Ok(StyledString::Text(rcstr!("Unmatched app pages")))
1345    }
1346
1347    async fn description(&self) -> Result<Option<StyledString>> {
1348        let page_paths = self
1349            .pages
1350            .iter()
1351            .map(|page| {
1352                let relative_path = self
1353                    .app_dir
1354                    .get_path_to(page)
1355                    .expect("authored page should be within the app directory");
1356                format!("- app/{}", relative_path)
1357            })
1358            .collect::<Vec<_>>()
1359            .join("\n");
1360        Ok(Some(StyledString::Text(
1361            format!(
1362                "The following page files do not match any complete route:\n{page_paths}\n\nEvery \
1363                 page must be part of at least one complete route. Add matching pages or default \
1364                 files for the sibling parallel route slots, or remove the unreachable pages."
1365            )
1366            .into(),
1367        )))
1368    }
1369}
1370
1371#[turbo_tasks::value]
1372struct DuplicateParallelRouteIssue {
1373    app_dir: FileSystemPath,
1374    previously_inserted_page: AppPage,
1375    page: AppPage,
1376}
1377
1378#[async_trait]
1379#[turbo_tasks::value_impl]
1380impl Issue for DuplicateParallelRouteIssue {
1381    async fn file_path(&self) -> Result<FileSystemPath> {
1382        self.app_dir.join(&self.page.to_string())
1383    }
1384
1385    fn stage(&self) -> IssueStage {
1386        IssueStage::ProcessModule
1387    }
1388
1389    async fn title(&self) -> Result<StyledString> {
1390        Ok(StyledString::Text(
1391            format!(
1392                "You cannot have two parallel pages that resolve to the same path. Please check \
1393                 {} and {}.",
1394                self.previously_inserted_page, self.page
1395            )
1396            .into(),
1397        ))
1398    }
1399}
1400
1401#[turbo_tasks::value]
1402struct MissingRootLayoutIssue {
1403    app_dir: FileSystemPath,
1404    page_path: FileSystemPath,
1405}
1406
1407#[async_trait]
1408#[turbo_tasks::value_impl]
1409impl Issue for MissingRootLayoutIssue {
1410    async fn file_path(&self) -> Result<FileSystemPath> {
1411        Ok(self.page_path.clone())
1412    }
1413
1414    fn stage(&self) -> IssueStage {
1415        IssueStage::AppStructure
1416    }
1417
1418    fn severity(&self) -> IssueSeverity {
1419        IssueSeverity::Error
1420    }
1421
1422    async fn title(&self) -> Result<StyledString> {
1423        let page_path = self
1424            .app_dir
1425            .get_path_to(&self.page_path)
1426            .context("page should be within the app directory")?;
1427
1428        Ok(StyledString::Text(
1429            format!(
1430                "{page_path} doesn't have a root layout. To fix this error, make sure every page \
1431                 has a root layout."
1432            )
1433            .into(),
1434        ))
1435    }
1436}
1437
1438#[turbo_tasks::value]
1439struct MissingDefaultParallelRouteIssue {
1440    app_dir: FileSystemPath,
1441    app_page: AppPage,
1442    slot_name: RcStr,
1443}
1444
1445#[turbo_tasks::function]
1446fn missing_default_parallel_route_issue(
1447    app_dir: FileSystemPath,
1448    app_page: AppPage,
1449    slot_name: RcStr,
1450) -> Vc<MissingDefaultParallelRouteIssue> {
1451    MissingDefaultParallelRouteIssue {
1452        app_dir,
1453        app_page,
1454        slot_name,
1455    }
1456    .cell()
1457}
1458
1459#[async_trait]
1460#[turbo_tasks::value_impl]
1461impl Issue for MissingDefaultParallelRouteIssue {
1462    async fn file_path(&self) -> Result<FileSystemPath> {
1463        self.app_dir
1464            .join(&self.app_page.to_string())?
1465            .join(&format!("@{}", self.slot_name))
1466    }
1467
1468    fn stage(&self) -> IssueStage {
1469        IssueStage::AppStructure
1470    }
1471
1472    fn severity(&self) -> IssueSeverity {
1473        IssueSeverity::Error
1474    }
1475
1476    async fn title(&self) -> Result<StyledString> {
1477        Ok(StyledString::Text(
1478            format!(
1479                "Missing required default.js file for parallel route at {}/@{}",
1480                self.app_page, self.slot_name
1481            )
1482            .into(),
1483        ))
1484    }
1485
1486    async fn description(&self) -> Result<Option<StyledString>> {
1487        Ok(Some(StyledString::Stack(vec![
1488            StyledString::Text(
1489                format!(
1490                    "The parallel route slot \"@{}\" is missing a default.js file. When using \
1491                     parallel routes, each slot must have a default.js file to serve as a \
1492                     fallback.",
1493                    self.slot_name
1494                )
1495                .into(),
1496            ),
1497            StyledString::Text(
1498                format!(
1499                    "Create a default.js file at: {}/@{}/default.js",
1500                    self.app_page, self.slot_name
1501                )
1502                .into(),
1503            ),
1504        ])))
1505    }
1506
1507    fn documentation_link(&self) -> RcStr {
1508        rcstr!("https://nextjs.org/docs/messages/slot-missing-default")
1509    }
1510}
1511
1512fn page_path_except_parallel(loader_tree: &AppPageLoaderTree) -> Option<AppPage> {
1513    if loader_tree.page.iter().any(|v| {
1514        matches!(
1515            v,
1516            PageSegment::CatchAll(..)
1517                | PageSegment::OptionalCatchAll(..)
1518                | PageSegment::Parallel(..)
1519        )
1520    }) {
1521        return None;
1522    }
1523
1524    if loader_tree.modules.page.is_some() {
1525        return Some(loader_tree.page.clone());
1526    }
1527
1528    if let Some(children) = loader_tree.parallel_routes.get("children") {
1529        return page_path_except_parallel(children);
1530    }
1531
1532    None
1533}
1534
1535/// Checks if a directory tree has child routes (non-parallel, non-group routes).
1536/// Leaf segments don't need default.js because there are no child routes
1537/// that could cause the parallel slot to unmatch.
1538fn has_child_routes(directory_tree: &PlainDirectoryTree) -> bool {
1539    for (name, subdirectory) in &directory_tree.subdirectories {
1540        // Skip parallel routes (start with '@')
1541        if is_parallel_route(name) {
1542            continue;
1543        }
1544
1545        // Skip route groups, but check if they have pages inside
1546        if is_group_route(name) {
1547            // Recursively check if the group has child routes
1548            if has_child_routes(subdirectory) {
1549                return true;
1550            }
1551            continue;
1552        }
1553
1554        // If we get here, it's a regular route segment (child route)
1555        return true;
1556    }
1557
1558    false
1559}
1560
1561/// Returns whether the filesystem declares a children slot at this level. Route groups are
1562/// transparent, while a named slot does not declare children for its parent layout. Once an
1563/// ordinary branch is entered, route targets inside deeper named slots still make it renderable.
1564fn has_declared_children_slot(directory_tree: &PlainDirectoryTree) -> bool {
1565    directory_tree.modules.page.is_some()
1566        || directory_tree.modules.default.is_some()
1567        || directory_tree
1568            .subdirectories
1569            .iter()
1570            .filter(|(name, _)| !is_parallel_route(name))
1571            .any(|(_, subdirectory)| subdirectory.contains_page_or_default)
1572}
1573
1574/// Collects named slots at the current URL level. Route groups are transparent, while ordinary
1575/// segments and parallel routes establish nested levels with their own layout ownership.
1576fn collect_named_slots_at_level(
1577    directory_tree: &PlainDirectoryTree,
1578    slots: &mut FxIndexSet<RcStr>,
1579) {
1580    for (name, subdirectory) in &directory_tree.subdirectories {
1581        if let Some(slot) = match_parallel_route(name) {
1582            if subdirectory.contains_page_or_default {
1583                slots.insert(slot.into());
1584            }
1585        } else if is_group_route(name) {
1586            collect_named_slots_at_level(subdirectory, slots);
1587        }
1588    }
1589}
1590
1591/// Records the filesystem slots owned by each layout. Loader trees can also contain structural
1592/// branches used to carry parallel-route state; those branches must not participate in matcher
1593/// completeness unless the owning layout actually declares the slot.
1594fn collect_declared_parallel_route_slots(
1595    directory_tree: &PlainDirectoryTree,
1596    slots_by_layout: &mut FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
1597) {
1598    if let Some(layout) = &directory_tree.modules.layout {
1599        let mut slots = FxIndexSet::default();
1600        if has_declared_children_slot(directory_tree) {
1601            slots.insert(rcstr!("children"));
1602        }
1603        collect_named_slots_at_level(directory_tree, &mut slots);
1604        slots_by_layout.insert(layout.clone(), slots);
1605    }
1606
1607    for subdirectory in directory_tree.subdirectories.values() {
1608        collect_declared_parallel_route_slots(subdirectory, slots_by_layout);
1609    }
1610}
1611
1612async fn check_duplicate(
1613    duplicate: &mut FxHashMap<AppPath, AppPage>,
1614    loader_tree: &AppPageLoaderTree,
1615    app_dir: FileSystemPath,
1616) -> Result<()> {
1617    let page_path = page_path_except_parallel(loader_tree);
1618
1619    if let Some(page_path) = page_path
1620        && let Some(prev) = duplicate.insert(AppPath::from(page_path.clone()), page_path.clone())
1621        && prev != page_path
1622    {
1623        DuplicateParallelRouteIssue {
1624            app_dir: app_dir.clone(),
1625            previously_inserted_page: prev.clone(),
1626            page: loader_tree.page.clone(),
1627        }
1628        .resolved_cell()
1629        .emit();
1630    }
1631
1632    Ok(())
1633}
1634
1635#[turbo_tasks::value(transparent)]
1636struct AppPageLoaderTreeOption(Option<ResolvedVc<AppPageLoaderTree>>);
1637
1638/// Creates the loader tree for a specific route (pathname / [AppPath]).
1639#[turbo_tasks::function]
1640async fn directory_tree_to_loader_tree(
1641    app_dir: FileSystemPath,
1642    global_metadata: Vc<GlobalMetadata>,
1643    directory_name: RcStr,
1644    directory_tree: Vc<DirectoryTree>,
1645    app_page: AppPage,
1646    // the page this loader tree is constructed for
1647    for_app_path: AppPath,
1648    explicit_parallel_route_children: Vc<bool>,
1649    strict_route_matching: Vc<bool>,
1650) -> Result<Vc<AppPageLoaderTreeOption>> {
1651    let plain_tree_vc = directory_tree.into_plain();
1652    let plain_tree = &*plain_tree_vc.await?;
1653    let strict_route_matching = *strict_route_matching.await?;
1654    let mut missing_defaults = Vec::new();
1655    let tree = directory_tree_to_loader_tree_internal(
1656        app_dir.clone(),
1657        global_metadata,
1658        directory_name,
1659        plain_tree,
1660        app_page.clone(),
1661        for_app_path,
1662        *explicit_parallel_route_children.await?,
1663        AppDirModules::default(),
1664        Some(&plain_tree.url_tree),
1665        &mut missing_defaults,
1666    )
1667    .await?;
1668
1669    // Strict matching handles incomplete routes after the finalized entrypoint is assembled.
1670    // Preserve the legacy per-tree missing-default diagnostics when strict matching is disabled.
1671    if !strict_route_matching {
1672        for (page, slot) in missing_defaults {
1673            missing_default_parallel_route_issue(app_dir.clone(), page, slot)
1674                .to_resolved()
1675                .await?
1676                .emit();
1677        }
1678    }
1679
1680    Ok(Vc::cell(tree.map(AppPageLoaderTree::resolved_cell)))
1681}
1682
1683/// Checks the current module if it needs to be updated with the default page.
1684/// If the module is already set, update the parent module to the same value.
1685/// If the parent module is set and module is not set, set the module to the parent module.
1686/// If the module and the parent module are not set, set them to the default value.
1687///
1688/// # Arguments
1689/// * `app_dir` - The application directory.
1690/// * `module` - The current module to check and update if it is not set.
1691/// * `parent_module` - The parent module to update if the current module is set or both are not
1692///   set.
1693/// * `file_path` - The file path to the default page if neither the current module nor the parent
1694///   module is set.
1695/// * `is_first_layer_group_route` - If true, the module will be overridden with the parent module
1696///   if it is not set.
1697async fn check_and_update_module_references(
1698    app_dir: FileSystemPath,
1699    module: &mut Option<FileSystemPath>,
1700    parent_module: &mut Option<FileSystemPath>,
1701    file_path: &str,
1702    is_first_layer_group_route: bool,
1703) -> Result<()> {
1704    match (module.as_mut(), parent_module.as_mut()) {
1705        // If the module is set, update the parent module to the same value
1706        (Some(module), _) => *parent_module = Some(module.clone()),
1707        // If we are in a first layer group route and we have a parent module, we want to override
1708        // a nonexistent module with the parent module
1709        (None, Some(parent_module)) if is_first_layer_group_route => {
1710            *module = Some(parent_module.clone())
1711        }
1712        // If we are not in a first layer group route, and the module is not set, and the parent
1713        // module is set, we do nothing
1714        (None, Some(_)) => {}
1715        // If the module is not set, and the parent module is not set, we override with the default
1716        // page. This can only happen in the root directory because after this the parent module
1717        // will always be set.
1718        (None, None) => {
1719            let default_page = get_next_package(app_dir).await?.join(file_path)?;
1720            *module = Some(default_page.clone());
1721            *parent_module = Some(default_page);
1722        }
1723    }
1724
1725    Ok(())
1726}
1727
1728/// Checks if the current directory is the root directory and if the module is not set.
1729/// If the module is not set, it will be set to the default page.
1730///
1731/// # Arguments
1732/// * `app_dir` - The application directory.
1733/// * `module` - The module to check and update if it is not set.
1734/// * `file_path` - The file path to the default page if the module is not set.
1735async fn check_and_update_global_module_references(
1736    app_dir: FileSystemPath,
1737    module: &mut Option<FileSystemPath>,
1738    file_path: &str,
1739) -> Result<()> {
1740    if module.is_none() {
1741        *module = Some(get_next_package(app_dir).await?.join(file_path)?);
1742    }
1743
1744    Ok(())
1745}
1746
1747async fn directory_tree_to_loader_tree_internal(
1748    app_dir: FileSystemPath,
1749    global_metadata: Vc<GlobalMetadata>,
1750    directory_name: RcStr,
1751    directory_tree: &PlainDirectoryTree,
1752    app_page: AppPage,
1753    // the page this loader tree is constructed for
1754    for_app_path: AppPath,
1755    explicit_parallel_route_children: bool,
1756    mut parent_modules: AppDirModules,
1757    url_tree: Option<&UrlSegmentTree>,
1758    missing_defaults: &mut Vec<(AppPage, RcStr)>,
1759) -> Result<Option<AppPageLoaderTree>> {
1760    let app_path = AppPath::from(app_page.clone());
1761
1762    if !for_app_path.contains(&app_path) {
1763        return Ok(None);
1764    }
1765
1766    let mut modules = directory_tree.modules.clone();
1767
1768    // Capture the current page for the metadata to calculate segment relative to
1769    // the corresponding page for the static metadata files.
1770    modules.metadata.base_page = Some(app_page.clone());
1771
1772    // the root directory in the app dir.
1773    let is_root_directory = app_page.is_root();
1774
1775    // If the first layer is a group route, we treat it as root layer
1776    let is_first_layer_group_route = app_page.is_first_layer_group_route();
1777
1778    // Handle the non-global modules that should always be overridden for top level groups or set to
1779    // the default page if they are not set.
1780    if is_root_directory || is_first_layer_group_route {
1781        check_and_update_module_references(
1782            app_dir.clone(),
1783            &mut modules.not_found,
1784            &mut parent_modules.not_found,
1785            "dist/client/components/builtin/not-found.js",
1786            is_first_layer_group_route,
1787        )
1788        .await?;
1789
1790        check_and_update_module_references(
1791            app_dir.clone(),
1792            &mut modules.forbidden,
1793            &mut parent_modules.forbidden,
1794            "dist/client/components/builtin/forbidden.js",
1795            is_first_layer_group_route,
1796        )
1797        .await?;
1798
1799        check_and_update_module_references(
1800            app_dir.clone(),
1801            &mut modules.unauthorized,
1802            &mut parent_modules.unauthorized,
1803            "dist/client/components/builtin/unauthorized.js",
1804            is_first_layer_group_route,
1805        )
1806        .await?;
1807    }
1808
1809    if is_root_directory {
1810        check_and_update_global_module_references(
1811            app_dir.clone(),
1812            &mut modules.global_error,
1813            "dist/client/components/builtin/global-error.js",
1814        )
1815        .await?;
1816    }
1817
1818    // For dynamic segments like [id], find all static siblings at the same URL level.
1819    // This is used by the client to determine if a prefetch can be reused when
1820    // navigating between routes that share the same parent layout.
1821    let static_siblings: Vec<RcStr> = if is_dynamic_segment(&directory_name) {
1822        url_tree
1823            .map(|t| {
1824                t.static_children()
1825                    .into_iter()
1826                    .filter(|s| s != &directory_name)
1827                    .collect()
1828            })
1829            .unwrap_or_default()
1830    } else {
1831        // Static segments don't need sibling info - only dynamic segments use it
1832        Vec::new()
1833    };
1834
1835    let mut tree = AppPageLoaderTree {
1836        page: app_page.clone(),
1837        segment: directory_name.clone(),
1838        parallel_routes: FxIndexMap::default(),
1839        modules: modules.without_leaves(),
1840        global_metadata: global_metadata.to_resolved().await?,
1841        static_siblings,
1842    };
1843
1844    let current_level_is_parallel_route = is_parallel_route(&directory_name);
1845
1846    if current_level_is_parallel_route {
1847        tree.segment = rcstr!("(__SLOT__)");
1848    }
1849
1850    if let Some(page) = (app_path == for_app_path || app_path.is_catchall())
1851        .then_some(modules.page)
1852        .flatten()
1853    {
1854        tree.parallel_routes.insert(
1855            rcstr!("children"),
1856            AppPageLoaderTree {
1857                page: app_page.clone(),
1858                segment: rcstr!("__PAGE__"),
1859                parallel_routes: FxIndexMap::default(),
1860                modules: AppDirModules {
1861                    page: Some(page),
1862                    metadata: modules.metadata,
1863                    ..Default::default()
1864                },
1865                global_metadata: global_metadata.to_resolved().await?,
1866                static_siblings: Vec::new(),
1867            },
1868        );
1869    }
1870
1871    let mut duplicate = FxHashMap::default();
1872
1873    for (subdir_name, subdirectory) in &directory_tree.subdirectories {
1874        let parallel_route_key = match_parallel_route(subdir_name);
1875
1876        let mut child_app_page = app_page.clone();
1877        let mut illegal_path_error = None;
1878
1879        // When constructing the app_page fails (e. g. due to limitations of the order),
1880        // we only want to emit the error when there are actual pages below that
1881        // directory.
1882        if let Err(e) = child_app_page.push_str(&normalize_underscore(subdir_name)) {
1883            illegal_path_error = Some(e);
1884        }
1885
1886        // Root/transparent segments don't consume a URL level; others descend.
1887        let child_url_tree: Option<&UrlSegmentTree> =
1888            if directory_name.is_empty() || is_url_transparent_segment(&directory_name) {
1889                url_tree
1890            } else {
1891                url_tree.and_then(|t| t.get_child(&directory_name))
1892            };
1893
1894        let subtree = Box::pin(directory_tree_to_loader_tree_internal(
1895            app_dir.clone(),
1896            global_metadata,
1897            subdir_name.clone(),
1898            subdirectory,
1899            child_app_page.clone(),
1900            for_app_path.clone(),
1901            explicit_parallel_route_children,
1902            parent_modules.clone(),
1903            child_url_tree,
1904            missing_defaults,
1905        ))
1906        .await?;
1907
1908        if let Some(illegal_path) = subtree.as_ref().and(illegal_path_error) {
1909            return Err(illegal_path);
1910        }
1911
1912        if let Some(subtree) = subtree {
1913            if let Some(key) = parallel_route_key {
1914                // Validate that parallel routes (except "children") have a default.js file.
1915                // This validation matches the webpack loader's logic but is implemented
1916                // differently due to Turbopack's single-pass recursive processing.
1917
1918                // Check if we're inside a catch-all route (i.e., the parallel route is a child
1919                // of a catch-all segment). Only skip validation if the slot is UNDER a catch-all.
1920                // For example:
1921                //   /[...catchAll]/@slot - is_inside_catchall = true (skip validation) ✓
1922                //   /@slot/[...catchAll] - is_inside_catchall = false (require default) ✓
1923                // The catch-all provides fallback behavior, so default.js is not required.
1924                let is_inside_catchall = app_page.is_catchall();
1925
1926                // Check if this is a leaf segment (no child routes).
1927                // Leaf segments don't need default.js because there are no child routes
1928                // that could cause the parallel slot to unmatch. For example:
1929                //   /repo-overview/@slot/page with no child routes - is_leaf_segment = true (skip
1930                // validation) ✓   /repo-overview/@slot/page with
1931                // /repo-overview/child/page - is_leaf_segment = false (require default) ✓
1932                // This also handles route groups correctly by filtering them out.
1933                let is_leaf_segment = !has_child_routes(directory_tree);
1934
1935                // Turbopack-specific: Check if the parallel slot has matching child routes.
1936                // In webpack, this is checked implicitly via the two-phase processing:
1937                // slots with content are processed first and skip validation in the second phase.
1938                // In Turbopack's single-pass approach, we check directly if the slot has child
1939                // routes. If the slot has child routes that match the parent's
1940                // child routes, it can render content for those routes and doesn't
1941                // need a default. For example:
1942                //   /parent/@slot/page + /parent/@slot/child + /parent/child - slot_has_children =
1943                // true (skip validation) ✓   /parent/@slot/page + /parent/child (no
1944                // @slot/child) - slot_has_children = false (require default) ✓
1945                let slot_has_children = has_child_routes(subdirectory);
1946
1947                if key != "children"
1948                    && subdirectory.modules.default.is_none()
1949                    && !is_inside_catchall
1950                    && !is_leaf_segment
1951                    && !slot_has_children
1952                {
1953                    missing_defaults.push((app_page.clone(), key.into()));
1954                }
1955
1956                tree.parallel_routes.insert(key.into(), subtree);
1957                continue;
1958            }
1959
1960            // skip groups which don't have a page match.
1961            if is_group_route(subdir_name) && !subtree.has_page() {
1962                continue;
1963            }
1964
1965            if subtree.has_page() {
1966                check_duplicate(&mut duplicate, &subtree, app_dir.clone()).await?;
1967            }
1968
1969            if let Some(current_tree) = tree.parallel_routes.get("children") {
1970                if current_tree.has_only_catchall()
1971                    && (!subtree.has_only_catchall()
1972                        || current_tree.get_specificity() < subtree.get_specificity())
1973                {
1974                    tree.parallel_routes
1975                        .insert(rcstr!("children"), subtree.clone());
1976                }
1977            } else {
1978                tree.parallel_routes.insert(rcstr!("children"), subtree);
1979            }
1980        } else if let Some(key) = parallel_route_key {
1981            bail!(
1982                "missing page or default for parallel route `{}` (page: {})",
1983                key,
1984                app_page
1985            );
1986        }
1987    }
1988
1989    // An interception match is a partial update of its host's slots. Retain
1990    // every non-intercepting sibling above the interception marker, but keep
1991    // normal matching semantics inside the newly selected subtree.
1992    let is_interception_host = !app_path.contains_interception()
1993        && tree
1994            .parallel_routes
1995            .iter()
1996            .any(|(_, parallel_tree)| parallel_tree.is_intercepting());
1997
1998    if is_interception_host {
1999        let keys_to_replace = tree
2000            .parallel_routes
2001            .iter()
2002            .filter(|(_, parallel_tree)| !parallel_tree.is_intercepting())
2003            .map(|(key, _)| key.clone())
2004            .collect::<Vec<_>>();
2005        let is_inside_catchall = app_page.is_catchall();
2006        let is_leaf_segment = !has_child_routes(directory_tree);
2007
2008        for key in keys_to_replace {
2009            let subdir_name: RcStr = format!("@{key}").into();
2010
2011            let default = if key == "children" {
2012                modules.default.clone()
2013            } else if let Some(subdirectory) = directory_tree.subdirectories.get(&subdir_name) {
2014                subdirectory.modules.default.clone()
2015            } else {
2016                None
2017            };
2018            let slot_has_children = directory_tree
2019                .subdirectories
2020                .get(&subdir_name)
2021                .is_some_and(has_child_routes);
2022
2023            // Only emit the issue if this is not the children slot and there's no default
2024            // component. The ordinary children route does not require a default.js file.
2025            // Also skip validation if the slot is UNDER a catch-all route or if this is a
2026            // leaf segment (no child routes), or if the slot has matching child routes.
2027            if default.is_none()
2028                && key != "children"
2029                && !is_inside_catchall
2030                && !is_leaf_segment
2031                && !slot_has_children
2032            {
2033                missing_defaults.push((app_page.clone(), key.clone()));
2034            }
2035
2036            tree.parallel_routes.insert(
2037                key.clone(),
2038                retained_route_tree(app_dir.clone(), global_metadata, app_page.clone()).await?,
2039            );
2040        }
2041    }
2042
2043    if tree.parallel_routes.is_empty() {
2044        if modules.default.is_some() || current_level_is_parallel_route {
2045            tree = default_route_tree(
2046                app_dir.clone(),
2047                global_metadata,
2048                app_page.clone(),
2049                modules.default.clone(),
2050                rcstr!("children"),
2051                for_app_path.clone(),
2052            )
2053            .await?;
2054        } else {
2055            return Ok(None);
2056        }
2057    } else if tree.parallel_routes.get("children").is_none()
2058        && (!explicit_parallel_route_children || has_declared_children_slot(directory_tree))
2059    {
2060        // `children` is only a slot when this level has ordinary route
2061        // content. Named-only layouts can carry their parallel route state
2062        // directly without a synthetic default child.
2063        let children = if is_interception_host {
2064            retained_route_tree(app_dir.clone(), global_metadata, app_page.clone()).await?
2065        } else {
2066            default_route_tree(
2067                app_dir.clone(),
2068                global_metadata,
2069                app_page.clone(),
2070                modules.default.clone(),
2071                rcstr!("children"),
2072                for_app_path.clone(),
2073            )
2074            .await?
2075        };
2076        tree.parallel_routes.insert(rcstr!("children"), children);
2077    }
2078
2079    Ok(Some(tree))
2080}
2081
2082async fn default_route_tree(
2083    app_dir: FileSystemPath,
2084    global_metadata: Vc<GlobalMetadata>,
2085    app_page: AppPage,
2086    default_component: Option<FileSystemPath>,
2087    slot_name: RcStr,
2088    for_app_path: AppPath,
2089) -> Result<AppPageLoaderTree> {
2090    let default = if let Some(default) = default_component {
2091        default
2092    } else {
2093        let contains_interception = for_app_path.contains_interception();
2094
2095        // Legacy slot discovery can synthesize a children slot inside an
2096        // interception subtree even when no ordinary route declares it.
2097        // Explicit children detection omits that structural child; this
2098        // fallback remains for applications that disable the flag.
2099        let default_file = if contains_interception && slot_name == "children" {
2100            "dist/client/components/builtin/default-null.js"
2101        } else {
2102            "dist/client/components/builtin/default.js"
2103        };
2104
2105        get_next_package(app_dir).await?.join(default_file)?
2106    };
2107
2108    synthetic_default_route_tree(global_metadata, app_page, default).await
2109}
2110
2111async fn retained_route_tree(
2112    app_dir: FileSystemPath,
2113    global_metadata: Vc<GlobalMetadata>,
2114    app_page: AppPage,
2115) -> Result<AppPageLoaderTree> {
2116    let default_null = get_next_package(app_dir)
2117        .await?
2118        .join("dist/client/components/builtin/default-null.js")?;
2119    synthetic_default_route_tree(global_metadata, app_page, default_null).await
2120}
2121
2122async fn synthetic_default_route_tree(
2123    global_metadata: Vc<GlobalMetadata>,
2124    app_page: AppPage,
2125    default: FileSystemPath,
2126) -> Result<AppPageLoaderTree> {
2127    Ok(AppPageLoaderTree {
2128        page: app_page,
2129        segment: rcstr!("__DEFAULT__"),
2130        parallel_routes: FxIndexMap::default(),
2131        modules: AppDirModules {
2132            default: Some(default),
2133            ..Default::default()
2134        },
2135        global_metadata: global_metadata.to_resolved().await?,
2136        static_siblings: Vec::new(),
2137    })
2138}
2139
2140#[turbo_tasks::function]
2141async fn directory_tree_to_entrypoints_internal(
2142    app_dir: FileSystemPath,
2143    global_metadata: ResolvedVc<GlobalMetadata>,
2144    is_global_not_found_enabled: Vc<bool>,
2145    explicit_parallel_route_children: Vc<bool>,
2146    strict_route_matching: Vc<bool>,
2147    next_mode: Vc<NextMode>,
2148    directory_name: RcStr,
2149    directory_tree: Vc<DirectoryTree>,
2150    app_page: AppPage,
2151    root_layouts: ResolvedVc<FileSystemPathVec>,
2152    root_params: ResolvedVc<RootParamVecOption>,
2153) -> Result<Vc<Entrypoints>> {
2154    let span = tracing::info_span!("build layout trees", name = display(&app_page));
2155    directory_tree_to_entrypoints_internal_untraced(
2156        app_dir,
2157        global_metadata,
2158        is_global_not_found_enabled,
2159        explicit_parallel_route_children,
2160        strict_route_matching,
2161        next_mode,
2162        directory_name,
2163        directory_tree,
2164        app_page,
2165        root_layouts,
2166        root_params,
2167    )
2168    .instrument(span)
2169    .await
2170}
2171
2172async fn directory_tree_to_entrypoints_internal_untraced(
2173    app_dir: FileSystemPath,
2174    global_metadata: ResolvedVc<GlobalMetadata>,
2175    is_global_not_found_enabled: Vc<bool>,
2176    explicit_parallel_route_children: Vc<bool>,
2177    strict_route_matching: Vc<bool>,
2178    next_mode: Vc<NextMode>,
2179    directory_name: RcStr,
2180    directory_tree: Vc<DirectoryTree>,
2181    app_page: AppPage,
2182    root_layouts: ResolvedVc<FileSystemPathVec>,
2183    root_params: ResolvedVc<RootParamVecOption>,
2184) -> Result<Vc<Entrypoints>> {
2185    let mut result = FxIndexMap::default();
2186
2187    let directory_tree_vc = directory_tree;
2188    let directory_tree = &*directory_tree.await?;
2189
2190    let subdirectories = &directory_tree.subdirectories;
2191    let modules = &directory_tree.modules;
2192    // Route can have its own segment config, also can inherit from the layout root
2193    // segment config. https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes#segment-runtime-option
2194    // Pass down layouts from each tree to apply segment config when adding route.
2195    let root_layouts = if let Some(layout) = &modules.layout {
2196        let mut layouts = root_layouts.owned().await?;
2197        layouts.push(layout.clone());
2198        ResolvedVc::cell(layouts)
2199    } else {
2200        root_layouts
2201    };
2202
2203    // TODO: `root_layouts` is a misnomer, they're just parent layouts
2204    let root_params = if root_params.await?.is_none() && (*root_layouts.await?).len() == 1 {
2205        // found a root layout. the params up-to-and-including this point are the root params
2206        // for all child segments
2207        ResolvedVc::cell(Some(
2208            app_page
2209                .0
2210                .iter()
2211                .filter_map(|segment| match segment {
2212                    PageSegment::Dynamic(param)
2213                    | PageSegment::CatchAll(param)
2214                    | PageSegment::OptionalCatchAll(param) => Some(param.clone()),
2215                    _ => None,
2216                })
2217                .collect::<Vec<RcStr>>(),
2218        ))
2219    } else {
2220        root_params
2221    };
2222
2223    if let Some(page_path) = &modules.page {
2224        if root_layouts.await?.is_empty() {
2225            MissingRootLayoutIssue {
2226                app_dir: app_dir.clone(),
2227                page_path: page_path.clone(),
2228            }
2229            .resolved_cell()
2230            .emit();
2231        }
2232
2233        let app_path = AppPath::from(app_page.clone());
2234
2235        let loader_tree = *directory_tree_to_loader_tree(
2236            app_dir.clone(),
2237            *global_metadata,
2238            directory_name.clone(),
2239            directory_tree_vc,
2240            app_page.clone(),
2241            app_path,
2242            explicit_parallel_route_children,
2243            strict_route_matching,
2244        )
2245        .await?;
2246
2247        let loader_tree =
2248            loader_tree.context("loader tree should be created for a page/default")?;
2249        let mut participating_page_files = FxIndexSet::default();
2250        loader_tree
2251            .await?
2252            .collect_page_files(&mut participating_page_files);
2253        add_app_page(
2254            app_dir.clone(),
2255            &mut result,
2256            app_page.complete(PageType::Page)?,
2257            loader_tree,
2258            participating_page_files.into_iter().collect(),
2259            root_params,
2260        );
2261    }
2262
2263    if let Some(route) = &modules.route {
2264        add_app_route(
2265            app_dir.clone(),
2266            &mut result,
2267            app_page.complete(PageType::Route)?,
2268            route.clone(),
2269            root_layouts,
2270            root_params,
2271        );
2272    }
2273
2274    let Metadata {
2275        icon,
2276        apple,
2277        twitter,
2278        open_graph,
2279        sitemap,
2280        base_page: _,
2281    } = &modules.metadata;
2282
2283    for meta in sitemap
2284        .iter()
2285        .cloned()
2286        .chain(icon.iter().cloned().map(MetadataItem::from))
2287        .chain(apple.iter().cloned().map(MetadataItem::from))
2288        .chain(twitter.iter().cloned().map(MetadataItem::from))
2289        .chain(open_graph.iter().cloned().map(MetadataItem::from))
2290    {
2291        let app_page = app_page.clone_push_str(&get_metadata_route_name(meta.clone()).await?)?;
2292
2293        add_app_metadata_route(
2294            app_dir.clone(),
2295            &mut result,
2296            normalize_metadata_route(app_page)?,
2297            meta,
2298            root_params,
2299        );
2300    }
2301
2302    // root path: /
2303    if app_page.is_root() {
2304        let GlobalMetadata {
2305            favicon,
2306            robots,
2307            manifest,
2308        } = &*global_metadata.await?;
2309
2310        for meta in favicon.iter().chain(robots.iter()).chain(manifest.iter()) {
2311            let app_page =
2312                app_page.clone_push_str(&get_metadata_route_name(meta.clone()).await?)?;
2313
2314            add_app_metadata_route(
2315                app_dir.clone(),
2316                &mut result,
2317                normalize_metadata_route(app_page)?,
2318                meta.clone(),
2319                root_params,
2320            );
2321        }
2322
2323        let mut modules = directory_tree.modules.clone();
2324
2325        // fill in the default modules for the not-found entrypoint
2326        if modules.layout.is_none() {
2327            modules.layout = Some(
2328                get_next_package(app_dir.clone())
2329                    .await?
2330                    .join("dist/client/components/builtin/layout.js")?,
2331            );
2332        }
2333
2334        if modules.not_found.is_none() {
2335            modules.not_found = Some(
2336                get_next_package(app_dir.clone())
2337                    .await?
2338                    .join("dist/client/components/builtin/not-found.js")?,
2339            );
2340        }
2341        if modules.forbidden.is_none() {
2342            modules.forbidden = Some(
2343                get_next_package(app_dir.clone())
2344                    .await?
2345                    .join("dist/client/components/builtin/forbidden.js")?,
2346            );
2347        }
2348        if modules.unauthorized.is_none() {
2349            modules.unauthorized = Some(
2350                get_next_package(app_dir.clone())
2351                    .await?
2352                    .join("dist/client/components/builtin/unauthorized.js")?,
2353            );
2354        }
2355        if modules.global_error.is_none() {
2356            modules.global_error = Some(
2357                get_next_package(app_dir.clone())
2358                    .await?
2359                    .join("dist/client/components/builtin/global-error.js")?,
2360            );
2361        }
2362
2363        // Next.js has this logic in "collect-app-paths", where the root not-found page
2364        // is considered as its own entry point.
2365
2366        // Determine if we enable the global not-found feature.
2367        let is_global_not_found_enabled = *is_global_not_found_enabled.await?;
2368        let use_global_not_found =
2369            is_global_not_found_enabled || modules.global_not_found.is_some();
2370
2371        let not_found_root_modules = modules.without_leaves();
2372        let not_found_tree = AppPageLoaderTree {
2373            page: app_page.clone(),
2374            segment: directory_name.clone(),
2375            parallel_routes: fxindexmap! {
2376                rcstr!("children") => AppPageLoaderTree {
2377                    page: app_page.clone(),
2378                    segment: rcstr!("/_not-found"),
2379                    parallel_routes: fxindexmap! {
2380                        rcstr!("children") => AppPageLoaderTree {
2381                            page: app_page.clone(),
2382                            segment: rcstr!("__PAGE__"),
2383                            parallel_routes: FxIndexMap::default(),
2384                            modules: if use_global_not_found {
2385                                // if global-not-found.js is present:
2386                                // leaf module only keeps page pointing to empty-stub
2387                                AppDirModules {
2388                                    // page is built-in/empty-stub
2389                                    page: Some(get_next_package(app_dir.clone())
2390                                        .await?
2391                                        .join("dist/client/components/builtin/empty-stub.js")?,
2392                                    ),
2393                                    ..Default::default()
2394                                }
2395                            } else {
2396                                // if global-not-found.js is not present:
2397                                // we search if we can compose root layout with the root not-found.js;
2398                                AppDirModules {
2399                                    page: match modules.not_found {
2400                                        Some(v) => Some(v),
2401                                        None => Some(get_next_package(app_dir.clone())
2402                                            .await?
2403                                            .join("dist/client/components/builtin/not-found.js")?,
2404                                        ),
2405                                    },
2406                                    ..Default::default()
2407                                }
2408                            },
2409                            global_metadata,
2410                            static_siblings: Vec::new(),
2411                        }
2412                    },
2413                    modules: AppDirModules {
2414                        ..Default::default()
2415                    },
2416                    global_metadata,
2417                    static_siblings: Vec::new(),
2418                },
2419            },
2420            modules: AppDirModules {
2421                // `global-not-found.js` does not need a layout since it's included.
2422                // Skip it if it's present.
2423                // Otherwise, we need to compose it with the root layout to compose with
2424                // not-found.js boundary.
2425                layout: if use_global_not_found {
2426                    match modules.global_not_found {
2427                        Some(v) => Some(v),
2428                        None => Some(
2429                            get_next_package(app_dir.clone())
2430                                .await?
2431                                .join("dist/client/components/builtin/global-not-found.js")?,
2432                        ),
2433                    }
2434                } else {
2435                    modules.layout
2436                },
2437                ..not_found_root_modules
2438            },
2439            global_metadata,
2440            static_siblings: Vec::new(),
2441        }
2442        .resolved_cell();
2443
2444        {
2445            let app_page = app_page
2446                .clone_push_str("_not-found")?
2447                .complete(PageType::Page)?;
2448
2449            add_app_page(
2450                app_dir.clone(),
2451                &mut result,
2452                app_page,
2453                not_found_tree,
2454                Vec::new(),
2455                root_params,
2456            );
2457        }
2458
2459        // Create production global error page only in build mode
2460        // This aligns with webpack: default Pages entries (including /_error) are only added when
2461        // the build isn't app-only. If the build is app-only (no user pages/api), we should still
2462        // expose the app global error so runtime errors render, but we shouldn't emit it otherwise.
2463        if matches!(*next_mode.await?, NextMode::Build) {
2464            // Create a `_global-error/page` route using user's global-error.js or built-in
2465            // fallback.
2466            let next_package = get_next_package(app_dir.clone()).await?;
2467            let global_error_tree = AppPageLoaderTree {
2468                page: app_page.clone(),
2469                segment: directory_name.clone(),
2470                parallel_routes: fxindexmap! {
2471                    rcstr!("children") => AppPageLoaderTree {
2472                        page: app_page.clone(),
2473                        segment: rcstr!("__PAGE__"),
2474                        parallel_routes: FxIndexMap::default(),
2475                        modules: AppDirModules {
2476                            page: Some(next_package
2477                                .join("dist/client/components/builtin/app-error.js")?),
2478                            ..Default::default()
2479                        },
2480                        global_metadata,
2481                        static_siblings: Vec::new(),
2482                    }
2483                },
2484                // global-error is needed for getGlobalErrorStyles to work during rendering.
2485                // Use user's custom global-error if defined, otherwise builtin fallback.
2486                modules: AppDirModules {
2487                    global_error: modules.global_error.clone(),
2488                    ..Default::default()
2489                },
2490                global_metadata,
2491                static_siblings: Vec::new(),
2492            }
2493            .resolved_cell();
2494
2495            let app_global_error_page = app_page
2496                .clone_push_str("_global-error")?
2497                .complete(PageType::Page)?;
2498            add_app_page(
2499                app_dir.clone(),
2500                &mut result,
2501                app_global_error_page,
2502                global_error_tree,
2503                Vec::new(),
2504                root_params,
2505            );
2506        }
2507    }
2508
2509    let app_page = &app_page;
2510    let directory_name = &directory_name;
2511    let subdirectories = subdirectories
2512        .iter()
2513        .map(|(subdir_name, &subdirectory)| {
2514            let app_dir = app_dir.clone();
2515
2516            async move {
2517                let mut child_app_page = app_page.clone();
2518                let mut illegal_path = None;
2519
2520                // When constructing the app_page fails (e. g. due to limitations of the order),
2521                // we only want to emit the error when there are actual pages below that
2522                // directory.
2523                if let Err(e) = child_app_page.push_str(&normalize_underscore(subdir_name)) {
2524                    illegal_path = Some(e);
2525                }
2526
2527                let map = directory_tree_to_entrypoints_internal(
2528                    app_dir.clone(),
2529                    *global_metadata,
2530                    is_global_not_found_enabled,
2531                    explicit_parallel_route_children,
2532                    strict_route_matching,
2533                    next_mode,
2534                    subdir_name.clone(),
2535                    *subdirectory,
2536                    child_app_page.clone(),
2537                    *root_layouts,
2538                    *root_params,
2539                )
2540                .await?;
2541
2542                if let Some(illegal_path) = illegal_path
2543                    && !map.is_empty()
2544                {
2545                    return Err(illegal_path);
2546                }
2547
2548                let mut loader_trees = Vec::new();
2549
2550                for (_, entrypoint) in map.iter() {
2551                    if let Entrypoint::AppPage { ref pages, .. } = *entrypoint {
2552                        for page in pages {
2553                            let app_path = AppPath::from(page.clone());
2554
2555                            let loader_tree = directory_tree_to_loader_tree(
2556                                app_dir.clone(),
2557                                *global_metadata,
2558                                directory_name.clone(),
2559                                directory_tree_vc,
2560                                app_page.clone(),
2561                                app_path,
2562                                explicit_parallel_route_children,
2563                                strict_route_matching,
2564                            );
2565                            loader_trees.push(loader_tree);
2566                        }
2567                    }
2568                }
2569                Ok((map, loader_trees))
2570            }
2571        })
2572        .try_join()
2573        .await?;
2574
2575    for (map, loader_trees) in subdirectories.iter() {
2576        let mut i = 0;
2577        for (_, entrypoint) in map.iter() {
2578            match entrypoint {
2579                Entrypoint::AppPage {
2580                    pages,
2581                    loader_tree: _,
2582                    participating_page_files: child_participating_page_files,
2583                    root_params,
2584                } => {
2585                    for page in pages {
2586                        let loader_tree = *loader_trees[i].await?;
2587                        i += 1;
2588
2589                        let loader_tree = loader_tree
2590                            .context("loader tree should be created for a page/default")?;
2591                        let mut participating_page_files = FxIndexSet::default();
2592                        loader_tree
2593                            .await?
2594                            .collect_page_files(&mut participating_page_files);
2595                        participating_page_files
2596                            .extend(child_participating_page_files.iter().cloned());
2597                        add_app_page(
2598                            app_dir.clone(),
2599                            &mut result,
2600                            page.clone(),
2601                            loader_tree,
2602                            participating_page_files.into_iter().collect(),
2603                            *root_params,
2604                        );
2605                    }
2606                }
2607                Entrypoint::AppRoute {
2608                    page,
2609                    path,
2610                    root_layouts,
2611                    root_params,
2612                } => {
2613                    add_app_route(
2614                        app_dir.clone(),
2615                        &mut result,
2616                        page.clone(),
2617                        path.clone(),
2618                        *root_layouts,
2619                        *root_params,
2620                    );
2621                }
2622                Entrypoint::AppMetadata {
2623                    page,
2624                    metadata,
2625                    root_params,
2626                } => {
2627                    add_app_metadata_route(
2628                        app_dir.clone(),
2629                        &mut result,
2630                        page.clone(),
2631                        metadata.clone(),
2632                        *root_params,
2633                    );
2634                }
2635            }
2636        }
2637    }
2638    Ok(Vc::cell(result))
2639}
2640
2641/// Returns the global metadata for an app directory.
2642#[turbo_tasks::function]
2643pub async fn get_global_metadata(
2644    app_dir: FileSystemPath,
2645    page_extensions: Vc<Vec<RcStr>>,
2646) -> Result<Vc<GlobalMetadata>> {
2647    let DirectoryContent::Entries(entries) = &*app_dir.read_dir().await? else {
2648        bail!("app_dir must be a directory")
2649    };
2650    let mut metadata = GlobalMetadata::default();
2651
2652    for (basename, entry) in entries {
2653        let DirectoryEntry::File(file) = entry else {
2654            continue;
2655        };
2656
2657        let Some(GlobalMetadataFileMatch {
2658            metadata_type,
2659            dynamic,
2660        }) = match_global_metadata_file(basename, &page_extensions.await?)
2661        else {
2662            continue;
2663        };
2664
2665        let entry = match metadata_type {
2666            "favicon" => &mut metadata.favicon,
2667            "manifest" => &mut metadata.manifest,
2668            "robots" => &mut metadata.robots,
2669            _ => continue,
2670        };
2671
2672        if dynamic {
2673            *entry = Some(MetadataItem::Dynamic { path: file.clone() });
2674        } else {
2675            *entry = Some(MetadataItem::Static { path: file.clone() });
2676        }
2677        // TODO(WEB-952) handle symlinks in app dir
2678    }
2679
2680    Ok(metadata.cell())
2681}
2682
2683#[turbo_tasks::value(shared)]
2684struct DirectoryTreeIssue {
2685    pub severity: IssueSeverity,
2686    pub app_dir: FileSystemPath,
2687    pub message: ResolvedVc<StyledString>,
2688}
2689
2690#[async_trait]
2691#[turbo_tasks::value_impl]
2692impl Issue for DirectoryTreeIssue {
2693    fn severity(&self) -> IssueSeverity {
2694        self.severity
2695    }
2696
2697    async fn title(&self) -> Result<StyledString> {
2698        Ok(StyledString::Text(rcstr!(
2699            "An issue occurred while preparing your Next.js app"
2700        )))
2701    }
2702
2703    fn stage(&self) -> IssueStage {
2704        IssueStage::AppStructure
2705    }
2706
2707    async fn file_path(&self) -> Result<FileSystemPath> {
2708        Ok(self.app_dir.clone())
2709    }
2710
2711    async fn description(&self) -> Result<Option<StyledString>> {
2712        Ok(Some((*self.message.await?).clone()))
2713    }
2714}