From b5d752667aabab59478d6f82cc6c1a27d37a8b44 Mon Sep 17 00:00:00 2001 From: Leah Date: Fri, 8 Sep 2023 20:25:13 +0200 Subject: [PATCH] feat(turbopack): add dynamic metadata support (#54995) Closes NEXT-1435 Closes WEB-1435 --- Cargo.lock | 170 +++++-- Cargo.toml | 3 - .../next-swc/crates/napi/src/app_structure.rs | 147 ++++-- packages/next-swc/crates/next-api/src/app.rs | 35 +- .../next-build/src/next_app/app_entries.rs | 29 +- packages/next-swc/crates/next-core/Cargo.toml | 1 + .../crates/next-core/src/app_source.rs | 180 ++++---- .../crates/next-core/src/app_structure.rs | 420 ++++++++++++------ .../crates/next-core/src/loader_tree.rs | 239 ++++++---- .../next-swc/crates/next-core/src/mode.rs | 20 +- .../next-core/src/next_app/app_page_entry.rs | 34 +- .../next-core/src/next_app/app_route_entry.rs | 14 +- .../next-core/src/next_app/metadata/image.rs | 148 ++++++ .../next-core/src/next_app/metadata/mod.rs | 341 ++++++++++++++ .../next-core/src/next_app/metadata/route.rs | 364 +++++++++++++++ .../crates/next-core/src/next_app/mod.rs | 56 ++- .../unsupported_dynamic_metadata_issue.rs | 54 --- .../crates/next-core/src/next_import_map.rs | 21 +- .../next-dev-tests/tests/integration.rs | 1 + .../app/implicit-metadata/input/app/test.tsx | 15 +- .../src/server/lib/router-utils/setup-dev.ts | 17 +- 21 files changed, 1771 insertions(+), 538 deletions(-) create mode 100644 packages/next-swc/crates/next-core/src/next_app/metadata/image.rs create mode 100644 packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs create mode 100644 packages/next-swc/crates/next-core/src/next_app/metadata/route.rs delete mode 100644 packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs diff --git a/Cargo.lock b/Cargo.lock index 5a64dc231155a..44ad805955fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,54 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "anstream" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "anstyle-wincon" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + [[package]] name = "any_ascii" version = "0.1.7" @@ -925,30 +973,36 @@ dependencies = [ [[package]] name = "clap" -version = "4.1.11" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42dfd32784433290c51d92c438bb72ea5063797fc3cc9a21a8c4346bebbb2098" +checksum = "6a13b88d2c62ff462f88e4a121f17a82c1af05693a2f192b5c38d14de73c19f6" dependencies = [ - "bitflags 2.3.3", + "clap_builder", "clap_derive", - "clap_lex 0.3.3", - "is-terminal", - "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb9faaa7c2ef94b2743a21f5a29e6f0010dff4caa69ac8e9d6cf8b6fa74da08" +dependencies = [ + "anstream", + "anstyle", + "clap_lex 0.5.1", "strsim", - "termcolor", ] [[package]] name = "clap_derive" -version = "4.1.9" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fddf67631444a3a3e3e5ac51c36a5e01335302de677bd78759eaa90ab1f46644" +checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873" dependencies = [ "heck 0.4.1", - "proc-macro-error", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.25", ] [[package]] @@ -962,12 +1016,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.3.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "033f6b7a4acb1f358c742aaca805c939ee73b4c6209ae4318ec7aca81c42e646" -dependencies = [ - "os_str_bytes", -] +checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" [[package]] name = "cloudabi" @@ -1000,6 +1051,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "combine" version = "4.6.6" @@ -1824,12 +1881,12 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" dependencies = [ - "atty", "humantime", + "is-terminal", "log", "regex", "termcolor", @@ -2431,15 +2488,15 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "httpmock" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6b56b6265f15908780cbee987912c1e98dbca675361f748291605a8a3a1df09" +checksum = "4b02e044d3b4c2f94936fb05f9649efa658ca788f44eb6b87554e2033fc8ce93" dependencies = [ "assert-json-diff", "async-object-pool", "async-trait", - "base64 0.13.1", - "clap 4.1.11", + "base64 0.21.0", + "clap 4.4.2", "crossbeam-utils", "env_logger", "form_urlencoded", @@ -2869,6 +2926,29 @@ dependencies = [ "log", ] +[[package]] +name = "lazy-regex" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57451d19ad5e289ff6c3d69c2a2424652995c42b79dafa11e9c4d5508c913c01" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a1d9139f0ee2e862e08a9c5d0ba0470f2aa21cd1e1aa1b1562f83116c725f" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.25", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -3171,7 +3251,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ - "regex-automata", + "regex-automata 0.1.10", ] [[package]] @@ -3547,7 +3627,7 @@ dependencies = [ "anyhow", "async-recursion", "base64 0.21.0", - "clap 4.1.11", + "clap 4.4.2", "console-subscriber", "dunce", "indexmap 1.9.3", @@ -3577,6 +3657,7 @@ dependencies = [ "futures", "indexmap 1.9.3", "indoc", + "lazy-regex", "lazy_static", "mime", "mime_guess", @@ -3601,7 +3682,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chromiumoxide", - "clap 4.1.11", + "clap 4.4.2", "console-subscriber", "criterion", "dunce", @@ -4747,13 +4828,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.3" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81ca098a9821bd52d6b24fd8b10bd081f47d39c22778cafaa75a2857a62c6390" +checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.2", + "regex-automata 0.3.8", + "regex-syntax 0.7.5", ] [[package]] @@ -4765,6 +4847,17 @@ dependencies = [ "regex-syntax 0.6.29", ] +[[package]] +name = "regex-automata" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.5", +] + [[package]] name = "regex-syntax" version = "0.6.29" @@ -4773,9 +4866,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.7.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "region" @@ -7168,11 +7261,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.28.2" +version = "1.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d7b1cfd2aa4011f2de74c2c4c63665e27a71006b0a192dcd2710272e73dfa2" +checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" dependencies = [ "autocfg", + "backtrace", "bytes", "libc", "mio 0.8.6", @@ -7819,7 +7913,7 @@ version = "0.1.0" source = "git+https://github.com/vercel/turbo.git?tag=turbopack-230901.3#d76ad75b0402cd9f9583ffba8885fc688e012a03" dependencies = [ "anyhow", - "clap 4.1.11", + "clap 4.4.2", "crossbeam-channel", "crossterm", "once_cell", @@ -7872,7 +7966,7 @@ version = "0.1.0" source = "git+https://github.com/vercel/turbo.git?tag=turbopack-230901.3#d76ad75b0402cd9f9583ffba8885fc688e012a03" dependencies = [ "anyhow", - "clap 4.1.11", + "clap 4.4.2", "indoc", "pathdiff", "serde_json", @@ -8387,6 +8481,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "uuid" version = "1.3.3" diff --git a/Cargo.toml b/Cargo.toml index 5c3972b864e39..c7b8764175097 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,6 @@ members = [ "packages/next-swc/crates/next-transform-strip-page-exports", ] -[profile.dev.package.swc_css_prefixer] -opt-level = 2 - # This is a workaround for wasm timeout issue [profile.dev.package."*"] debug-assertions = false diff --git a/packages/next-swc/crates/napi/src/app_structure.rs b/packages/next-swc/crates/napi/src/app_structure.rs index 90e3458fa5e8c..0b0d79828472a 100644 --- a/packages/next-swc/crates/napi/src/app_structure.rs +++ b/packages/next-swc/crates/napi/src/app_structure.rs @@ -8,7 +8,7 @@ use napi::{ }; use next_core::app_structure::{ find_app_dir, get_entrypoints as get_entrypoints_impl, Components, Entrypoint, Entrypoints, - LoaderTree, MetadataWithAltItem, + LoaderTree, MetadataItem, MetadataWithAltItem, }; use serde::{Deserialize, Serialize}; use turbo_tasks::{ReadRef, Vc}; @@ -41,6 +41,8 @@ struct LoaderTreeForJs { parallel_routes: HashMap>, #[turbo_tasks(trace_ignore)] components: ComponentsForJs, + #[turbo_tasks(trace_ignore)] + global_metadata: GlobalMetadataForJs, } #[derive(PartialEq, Eq, Serialize, Deserialize, ValueDebugFormat, TraceRawVcs)] @@ -101,20 +103,31 @@ struct ComponentsForJs { #[serde(rename_all = "camelCase")] struct MetadataForJs { #[serde(skip_serializing_if = "Vec::is_empty")] - icon: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - apple: Vec, + icon: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] - twitter: Vec, + apple: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] - open_graph: Vec, + twitter: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] - favicon: Vec, + open_graph: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + sitemap: Option, +} + +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)] +#[serde(rename_all = "camelCase")] +struct GlobalMetadataForJs { + #[serde(skip_serializing_if = "Option::is_none")] + favicon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + robots: Option, + #[serde(skip_serializing_if = "Option::is_none")] + manifest: Option, } #[derive(Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)] #[serde(tag = "type", rename_all = "camelCase")] -enum MetadataForJsItem { +enum MetadataWithAltItemForJs { Static { path: String, alt_path: Option, @@ -124,6 +137,13 @@ enum MetadataForJsItem { }, } +#[derive(Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)] +#[serde(tag = "type", rename_all = "camelCase")] +enum MetadataItemForJs { + Static { path: String }, + Dynamic { path: String }, +} + async fn prepare_components_for_js( project_path: Vc, components: Vc, @@ -158,60 +178,88 @@ async fn prepare_components_for_js( add(&mut result.not_found, project_path, not_found).await?; add(&mut result.default, project_path, default).await?; add(&mut result.route, project_path, route).await?; - async fn add_meta<'a>( - meta: &mut Vec, - project_path: Vc, - value: impl Iterator, - ) -> Result<()> { - let mut value = value.peekable(); - if value.peek().is_some() { - *meta = value - .map(|value| async move { - Ok(match value { - MetadataWithAltItem::Static { path, alt_path } => { - let path = fs_path_to_path(project_path, *path).await?; - let alt_path = if let Some(alt_path) = alt_path { - Some(fs_path_to_path(project_path, *alt_path).await?) - } else { - None - }; - MetadataForJsItem::Static { path, alt_path } - } - MetadataWithAltItem::Dynamic { path } => { - let path = fs_path_to_path(project_path, *path).await?; - MetadataForJsItem::Dynamic { path } - } - }) - }) - .try_join() - .await?; - } - Ok::<_, anyhow::Error>(()) - } + let meta = &mut result.metadata; - add_meta(&mut meta.icon, project_path, metadata.icon.iter()).await?; - add_meta(&mut meta.apple, project_path, metadata.apple.iter()).await?; - add_meta(&mut meta.twitter, project_path, metadata.twitter.iter()).await?; - add_meta( + add_meta_vec(&mut meta.icon, project_path, metadata.icon.iter()).await?; + add_meta_vec(&mut meta.apple, project_path, metadata.apple.iter()).await?; + add_meta_vec(&mut meta.twitter, project_path, metadata.twitter.iter()).await?; + add_meta_vec( &mut meta.open_graph, project_path, metadata.open_graph.iter(), ) .await?; - add_meta(&mut meta.favicon, project_path, metadata.favicon.iter()).await?; + add_meta(&mut meta.sitemap, project_path, metadata.sitemap).await?; Ok(result) } +async fn add_meta_vec<'a>( + meta: &mut Vec, + project_path: Vc, + value: impl Iterator, +) -> Result<()> { + let mut value = value.peekable(); + if value.peek().is_some() { + *meta = value + .map(|value| async move { + Ok(match value { + MetadataWithAltItem::Static { path, alt_path } => { + let path = fs_path_to_path(project_path, *path).await?; + let alt_path = if let Some(alt_path) = alt_path { + Some(fs_path_to_path(project_path, *alt_path).await?) + } else { + None + }; + MetadataWithAltItemForJs::Static { path, alt_path } + } + MetadataWithAltItem::Dynamic { path } => { + let path = fs_path_to_path(project_path, *path).await?; + MetadataWithAltItemForJs::Dynamic { path } + } + }) + }) + .try_join() + .await?; + } + + Ok(()) +} + +async fn add_meta<'a>( + meta: &mut Option, + project_path: Vc, + value: Option, +) -> Result<()> { + if value.is_some() { + *meta = match value { + Some(MetadataItem::Static { path }) => { + let path = fs_path_to_path(project_path, path).await?; + Some(MetadataItemForJs::Static { path }) + } + Some(MetadataItem::Dynamic { path }) => { + let path = fs_path_to_path(project_path, path).await?; + Some(MetadataItemForJs::Dynamic { path }) + } + None => None, + }; + } + + Ok(()) +} + #[turbo_tasks::function] async fn prepare_loader_tree_for_js( project_path: Vc, loader_tree: Vc, ) -> Result> { let LoaderTree { + page: _, segment, parallel_routes, components, + global_metadata, } = &*loader_tree.await?; + let parallel_routes = parallel_routes .iter() .map(|(key, &value)| async move { @@ -224,11 +272,21 @@ async fn prepare_loader_tree_for_js( .await? .into_iter() .collect(); + let components = prepare_components_for_js(project_path, *components).await?; + + let global_metadata = global_metadata.await?; + + let mut meta = GlobalMetadataForJs::default(); + add_meta(&mut meta.favicon, project_path, global_metadata.favicon).await?; + add_meta(&mut meta.manifest, project_path, global_metadata.manifest).await?; + add_meta(&mut meta.robots, project_path, global_metadata.robots).await?; + Ok(LoaderTreeForJs { segment: segment.clone(), parallel_routes, components, + global_metadata: meta, } .cell()) } @@ -251,6 +309,9 @@ async fn prepare_entrypoints_for_js( Entrypoint::AppRoute { path, .. } => EntrypointForJs::AppRoute { path: fs_path_to_path(project_path, path).await?, }, + Entrypoint::AppMetadata { metadata, .. } => EntrypointForJs::AppRoute { + path: fs_path_to_path(project_path, metadata.into_path()).await?, + }, }; Ok((key, value)) } diff --git a/packages/next-swc/crates/next-api/src/app.rs b/packages/next-swc/crates/next-api/src/app.rs index 295b57369a6d3..9fe4e8c66a018 100644 --- a/packages/next-swc/crates/next-api/src/app.rs +++ b/packages/next-swc/crates/next-api/src/app.rs @@ -3,12 +3,13 @@ use next_core::{ all_server_paths, app_structure::{ get_entrypoints, Entrypoint as AppEntrypoint, Entrypoints as AppEntrypoints, LoaderTree, + MetadataItem, }, get_edge_resolve_options_context, mode::NextMode, next_app::{ get_app_client_references_chunks, get_app_client_shared_chunks, get_app_page_entry, - get_app_route_entry, AppEntry, AppPage, + get_app_route_entry, metadata::route::get_app_metadata_route_entry, AppEntry, AppPage, }, next_client::{ get_client_module_options_context, get_client_resolve_options_context, @@ -113,6 +114,11 @@ impl AppProject { self.app_dir } + #[turbo_tasks::function] + fn mode(&self) -> Vc { + self.mode.cell() + } + #[turbo_tasks::function] fn app_entrypoints(&self) -> Vc { get_entrypoints(self.app_dir, self.project.next_config().page_extensions()) @@ -395,6 +401,16 @@ pub async fn app_entry_point_to_route( .cell(), ), }, + AppEntrypoint::AppMetadata { page, metadata } => Route::AppRoute { + endpoint: Vc::upcast( + AppEndpoint { + ty: AppEndpointType::Metadata { metadata }, + app_project, + page, + } + .cell(), + ), + }, } .cell() } @@ -414,6 +430,9 @@ enum AppEndpointType { Route { path: Vc, }, + Metadata { + metadata: MetadataItem, + }, } #[turbo_tasks::value] @@ -431,7 +450,6 @@ impl AppEndpoint { self.app_project.rsc_module_context(), self.app_project.edge_rsc_module_context(), loader_tree, - self.app_project.app_dir(), self.page.clone(), self.app_project.project().project_path(), ) @@ -448,6 +466,18 @@ impl AppEndpoint { ) } + #[turbo_tasks::function] + async fn app_metadata_entry(&self, metadata: MetadataItem) -> Result> { + Ok(get_app_metadata_route_entry( + self.app_project.rsc_module_context(), + self.app_project.edge_rsc_module_context(), + self.app_project.project().project_path(), + self.page.clone(), + *self.app_project.mode().await?, + metadata, + )) + } + #[turbo_tasks::function] fn output_assets(self: Vc) -> Vc { self.output().output_assets() @@ -465,6 +495,7 @@ impl AppEndpoint { // as we know we won't have any client references. However, for now, for simplicity's // sake, we just do the same thing as for pages. AppEndpointType::Route { path } => (self.app_route_entry(path), "route"), + AppEndpointType::Metadata { metadata } => (self.app_metadata_entry(metadata), "route"), }; let node_root = this.app_project.project().node_root(); diff --git a/packages/next-swc/crates/next-build/src/next_app/app_entries.rs b/packages/next-swc/crates/next-build/src/next_app/app_entries.rs index 9c7271ecdd66d..b9d3f38217436 100644 --- a/packages/next-swc/crates/next-build/src/next_app/app_entries.rs +++ b/packages/next-swc/crates/next-build/src/next_app/app_entries.rs @@ -2,11 +2,11 @@ use std::collections::HashMap; use anyhow::Result; use next_core::{ - app_structure::{find_app_dir_if_enabled, get_entrypoints, get_global_metadata, Entrypoint}, + app_structure::{find_app_dir_if_enabled, get_entrypoints, Entrypoint}, mode::NextMode, next_app::{ get_app_client_shared_chunks, get_app_page_entry, get_app_route_entry, - get_app_route_favicon_entry, AppEntry, ClientReferencesChunks, + metadata::route::get_app_metadata_route_entry, AppEntry, ClientReferencesChunks, }, next_client::{ get_client_module_options_context, get_client_resolve_options_context, @@ -184,7 +184,7 @@ pub async fn get_app_entries( rsc_resolve_options_context, ); - let mut entries = entrypoints + let entries = entrypoints .await? .iter() .map(|(_, entrypoint)| async move { @@ -194,7 +194,6 @@ pub async fn get_app_entries( // TODO add edge support rsc_context, *loader_tree, - app_dir, page.clone(), project_root, ), @@ -206,24 +205,20 @@ pub async fn get_app_entries( page.clone(), project_root, ), + Entrypoint::AppMetadata { page, metadata } => get_app_metadata_route_entry( + rsc_context, + // TODO add edge support + rsc_context, + project_root, + page.clone(), + mode, + *metadata, + ), }) }) .try_join() .await?; - let global_metadata = get_global_metadata(app_dir, next_config.page_extensions()); - let global_metadata = global_metadata.await?; - - if let Some(favicon) = global_metadata.favicon { - entries.push(get_app_route_favicon_entry( - rsc_context, - // TODO add edge support - rsc_context, - favicon, - project_root, - )); - } - let client_context = ModuleAssetContext::new( Vc::cell(Default::default()), client_compile_time_info, diff --git a/packages/next-swc/crates/next-core/Cargo.toml b/packages/next-swc/crates/next-core/Cargo.toml index 38bcb02a3cda6..1347b942a12fc 100644 --- a/packages/next-swc/crates/next-core/Cargo.toml +++ b/packages/next-swc/crates/next-core/Cargo.toml @@ -14,6 +14,7 @@ async-recursion = { workspace = true } async-trait = { workspace = true } base64 = "0.21.0" const_format = "0.2.30" +lazy-regex = "3.0.1" once_cell = { workspace = true } qstring = { workspace = true } regex = { workspace = true } diff --git a/packages/next-swc/crates/next-core/src/app_source.rs b/packages/next-swc/crates/next-core/src/app_source.rs index 4265f52139b4b..24cba8b73e00c 100644 --- a/packages/next-swc/crates/next-core/src/app_source.rs +++ b/packages/next-swc/crates/next-core/src/app_source.rs @@ -1,10 +1,11 @@ -use std::{collections::HashMap, io::Write as _, iter::once}; +use std::{collections::HashMap, io::Write as _}; use anyhow::{bail, Result}; use indexmap::indexmap; use indoc::formatdoc; +use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use turbo_tasks::Vc; +use turbo_tasks::{trace::TraceRawVcs, TaskInput, Vc}; use turbopack_binding::{ turbo::{ tasks::Value, @@ -19,7 +20,6 @@ use turbopack_binding::{ context::AssetContext, environment::ServerAddr, file_source::FileSource, - issue::IssueExt, reference_type::{ EcmaScriptModulesReferenceSubType, EntryReferenceSubType, ReferenceType, }, @@ -30,7 +30,6 @@ use turbopack_binding::{ dev_server::{ html::DevHtmlAsset, source::{ - asset_graph::AssetGraphContentSource, combined::CombinedContentSource, route_tree::{BaseSegment, RouteType}, ContentSource, ContentSourceData, ContentSourceExt, NoContentSource, @@ -47,7 +46,6 @@ use turbopack_binding::{ }, NodeEntry, NodeRenderingEntry, }, - r#static::fixed::FixedStaticAsset, turbopack::{transition::Transition, ModuleAssetContext}, }, }; @@ -55,17 +53,14 @@ use turbopack_binding::{ use crate::{ app_render::next_server_component_transition::NextServerComponentTransition, app_segment_config::{parse_segment_config_from_loader_tree, parse_segment_config_from_source}, - app_structure::{ - get_entrypoints, get_global_metadata, Entrypoint, GlobalMetadata, LoaderTree, MetadataItem, - OptionAppDir, - }, + app_structure::{get_entrypoints, Entrypoint, LoaderTree, MetadataItem, OptionAppDir}, bootstrap::{route_bootstrap, BootstrapConfig}, embed_js::{next_asset, next_js_file_path}, env::env_for_js, fallback::get_fallback_page, loader_tree::{LoaderTreeModule, ServerComponentTransition}, mode::NextMode, - next_app::{AppPage, AppPath, PathSegment, UnsupportedDynamicMetadataIssue}, + next_app::{metadata::route::get_app_metadata_route_source, AppPage, AppPath, PathSegment}, next_client::{ context::{ get_client_assets_path, get_client_module_options_context, @@ -599,7 +594,8 @@ pub async fn create_app_source( return Ok(Vc::upcast(NoContentSource::new())); }; let entrypoints = get_entrypoints(app_dir, next_config.page_extensions()); - let metadata = get_global_metadata(app_dir, next_config.page_extensions()); + + let mode = NextMode::DevServer; let context_ssr = app_context( project_path, @@ -610,7 +606,7 @@ pub async fn create_app_source( client_chunking_context, client_compile_time_info, true, - NextMode::DevServer, + mode, next_config, server_addr, output_path, @@ -624,7 +620,7 @@ pub async fn create_app_source( client_chunking_context, client_compile_time_info, false, - NextMode::DevServer, + mode, next_config, server_addr, output_path, @@ -671,6 +667,7 @@ pub async fn create_app_source( ), Entrypoint::AppRoute { ref page, path } => create_app_route_source_for_route( page.clone(), + mode, path, context_ssr, project_path, @@ -681,12 +678,20 @@ pub async fn create_app_source( output_path, render_data, ), + Entrypoint::AppMetadata { ref page, metadata } => create_app_route_source_for_metadata( + page.clone(), + mode, + context_ssr, + project_path, + app_dir, + env, + server_root, + server_runtime_entries, + output_path, + render_data, + metadata, + ), }) - .chain(once(create_global_metadata_source( - app_dir, - metadata, - server_root, - ))) .collect(); if let Some(&Entrypoint::AppPage { @@ -717,50 +722,6 @@ pub async fn create_app_source( Ok(Vc::upcast(CombinedContentSource { sources }.cell())) } -#[turbo_tasks::function] -async fn create_global_metadata_source( - app_dir: Vc, - metadata: Vc, - server_root: Vc, -) -> Result>> { - let metadata = metadata.await?; - let mut unsupported_metadata = Vec::new(); - let mut sources = Vec::new(); - for (server_path, item) in [ - ("robots.txt", metadata.robots), - ("favicon.ico", metadata.favicon), - ("sitemap.xml", metadata.sitemap), - ] { - let Some(item) = item else { - continue; - }; - match item { - MetadataItem::Static { path } => { - let asset = FixedStaticAsset::new( - server_root.join(server_path.to_string()), - Vc::upcast(FileSource::new(path)), - ); - sources.push(Vc::upcast(AssetGraphContentSource::new_eager( - server_root, - Vc::upcast(asset), - ))) - } - MetadataItem::Dynamic { path } => { - unsupported_metadata.push(path); - } - } - } - if !unsupported_metadata.is_empty() { - UnsupportedDynamicMetadataIssue { - app_dir, - files: unsupported_metadata, - } - .cell() - .emit(); - } - Ok(Vc::upcast(CombinedContentSource { sources }.cell())) -} - #[turbo_tasks::function] async fn create_app_page_source_for_route( page: AppPage, @@ -794,7 +755,6 @@ async fn create_app_page_source_for_route( Vc::upcast( AppRenderer { runtime_entries, - app_dir, context_ssr, context, server_root, @@ -839,7 +799,6 @@ async fn create_app_not_found_page_source( Vc::upcast( AppRenderer { runtime_entries, - app_dir, context_ssr, context, server_root, @@ -860,6 +819,7 @@ async fn create_app_not_found_page_source( #[turbo_tasks::function] async fn create_app_route_source_for_route( page: AppPage, + mode: NextMode, entry_path: Vc, context_ssr: Vc, project_path: Vc, @@ -890,7 +850,58 @@ async fn create_app_route_source_for_route( context: context_ssr, runtime_entries, server_root, - entry_path, + entry: AppRouteEntry::Path(entry_path), + mode, + project_path, + intermediate_output_path: intermediate_output_path_root, + output_root: intermediate_output_path_root, + app_dir, + } + .cell(), + ), + render_data, + should_debug("app_source"), + ); + + Ok(source.issue_file_path(app_dir, format!("Next.js App Route {app_path}"))) +} + +#[turbo_tasks::function] +async fn create_app_route_source_for_metadata( + page: AppPage, + mode: NextMode, + context_ssr: Vc, + project_path: Vc, + app_dir: Vc, + env: Vc>, + server_root: Vc, + runtime_entries: Vc, + intermediate_output_path_root: Vc, + render_data: Vc, + metadata: MetadataItem, +) -> Result>> { + let app_path = AppPath::from(page.clone()); + let pathname_vc = Vc::cell(app_path.to_string()); + + let params_matcher = NextParamsMatcher::new(pathname_vc); + + let (base_segments, route_type) = app_path_to_segments(&app_path)?; + + let source = create_node_api_source( + project_path, + env, + base_segments, + route_type, + server_root, + Vc::upcast(params_matcher), + pathname_vc, + Vc::upcast( + AppRoute { + context: context_ssr, + runtime_entries, + server_root, + entry: AppRouteEntry::Metadata { metadata, page }, + mode, project_path, intermediate_output_path: intermediate_output_path_root, output_root: intermediate_output_path_root, @@ -909,7 +920,6 @@ async fn create_app_route_source_for_route( #[turbo_tasks::value] struct AppRenderer { runtime_entries: Vc, - app_dir: Vc, context_ssr: Vc, context: Vc, project_path: Vc, @@ -924,7 +934,6 @@ impl AppRenderer { async fn entry(self: Vc, with_ssr: bool) -> Result> { let AppRenderer { runtime_entries, - app_dir, context_ssr, context, project_path, @@ -955,15 +964,6 @@ impl AppRenderer { ) .await?; - if !loader_tree_module.unsupported_metadata.is_empty() { - UnsupportedDynamicMetadataIssue { - app_dir, - files: loader_tree_module.unsupported_metadata, - } - .cell() - .emit(); - } - let mut result = RopeBuilder::from( formatdoc!( " @@ -1078,12 +1078,22 @@ impl NodeEntry for AppRenderer { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TaskInput, TraceRawVcs)] +pub enum AppRouteEntry { + Path(Vc), + Metadata { + metadata: MetadataItem, + page: AppPage, + }, +} + /// The node.js renderer api routes in the app directory #[turbo_tasks::value] struct AppRoute { runtime_entries: Vc, context: Vc, - entry_path: Vc, + entry: AppRouteEntry, + mode: NextMode, intermediate_output_path: Vc, project_path: Vc, server_root: Vc, @@ -1110,13 +1120,19 @@ impl AppRoute { .build(), ); - let entry_file_source = FileSource::new(this.entry_path); + let entry_file_source = match this.entry { + AppRouteEntry::Path(path) => Vc::upcast(FileSource::new(path)), + AppRouteEntry::Metadata { metadata, ref page } => { + get_app_metadata_route_source(page.clone(), this.mode, metadata) + } + }; + let entry_asset = this.context.process( - Vc::upcast(entry_file_source), + entry_file_source, Value::new(ReferenceType::Entry(EntryReferenceSubType::AppRoute)), ); - let config = parse_segment_config_from_source(entry_asset, Vc::upcast(entry_file_source)); + let config = parse_segment_config_from_source(entry_asset, entry_file_source); let module = match config.await?.runtime { Some(NextRuntime::NodeJs) | None => { let bootstrap_asset = next_asset("entry/app/route.ts".to_string()); @@ -1125,7 +1141,7 @@ impl AppRoute { .context .with_transition("next-route".to_string()) .process( - Vc::upcast(entry_file_source), + entry_file_source, Value::new(ReferenceType::Entry(EntryReferenceSubType::AppRoute)), ); @@ -1144,7 +1160,7 @@ impl AppRoute { .context .with_transition("next-edge-route".to_string()) .process( - Vc::upcast(entry_file_source), + entry_file_source, Value::new(ReferenceType::Entry(EntryReferenceSubType::AppRoute)), ); diff --git a/packages/next-swc/crates/next-core/src/app_structure.rs b/packages/next-swc/crates/next-core/src/app_structure.rs index a3597079af6c0..c0cf554cc2525 100644 --- a/packages/next-swc/crates/next-core/src/app_structure.rs +++ b/packages/next-swc/crates/next-core/src/app_structure.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use anyhow::{bail, Result}; use indexmap::{ @@ -6,8 +6,6 @@ use indexmap::{ map::{Entry, OccupiedEntry}, IndexMap, }; -use once_cell::sync::Lazy; -use regex::Regex; use serde::{Deserialize, Serialize}; use turbo_tasks::{ debug::ValueDebugFormat, trace::TraceRawVcs, Completion, Completions, TaskInput, ValueToString, @@ -19,7 +17,13 @@ use turbopack_binding::{ }; use crate::{ - next_app::{AppPage, AppPath}, + next_app::{ + metadata::{ + match_global_metadata_file, match_local_metadata_file, normalize_metadata_route, + GlobalMetadataFileMatch, MetadataFileMatch, + }, + AppPage, AppPath, PageType, + }, next_config::NextConfig, next_import_map::get_next_package, }; @@ -108,6 +112,49 @@ pub enum MetadataItem { Dynamic { path: Vc }, } +#[turbo_tasks::function] +pub async fn get_metadata_route_name(meta: MetadataItem) -> Result> { + Ok(match meta { + MetadataItem::Static { path } => { + let path_value = path.await?; + Vc::cell(path_value.file_name().to_string()) + } + MetadataItem::Dynamic { path } => { + let Some(stem) = &*path.file_stem().await? else { + bail!( + "unable to resolve file stem for metadata item at {}", + path.to_string().await? + ); + }; + + match stem.as_str() { + "robots" => Vc::cell("robots.txt".to_string()), + "manifest" => Vc::cell("manifest.webmanifest".to_string()), + "sitemap" => Vc::cell("sitemap.xml".to_string()), + _ => Vc::cell(stem.clone()), + } + } + }) +} + +impl MetadataItem { + pub fn into_path(self) -> Vc { + match self { + MetadataItem::Static { path } => path, + MetadataItem::Dynamic { path } => path, + } + } +} + +impl From for MetadataItem { + fn from(value: MetadataWithAltItem) -> Self { + match value { + MetadataWithAltItem::Static { path, .. } => MetadataItem::Static { path }, + MetadataWithAltItem::Dynamic { path } => MetadataItem::Dynamic { path }, + } + } +} + /// Metadata file that can be placed in any segment of the app directory. #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TraceRawVcs)] pub struct Metadata { @@ -119,10 +166,8 @@ pub struct Metadata { pub twitter: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub open_graph: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub favicon: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub manifest: Option, + pub sitemap: Option, } impl Metadata { @@ -132,15 +177,13 @@ impl Metadata { apple, twitter, open_graph, - favicon, - manifest, + sitemap, } = self; icon.is_empty() && apple.is_empty() && twitter.is_empty() && open_graph.is_empty() - && favicon.is_empty() - && manifest.is_none() + && sitemap.is_none() } fn merge(a: &Self, b: &Self) -> Self { @@ -154,8 +197,7 @@ impl Metadata { .chain(b.open_graph.iter()) .copied() .collect(), - favicon: a.favicon.iter().chain(b.favicon.iter()).copied().collect(), - manifest: a.manifest.or(b.manifest), + sitemap: a.sitemap.or(b.sitemap), } } } @@ -169,7 +211,7 @@ pub struct GlobalMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub robots: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub sitemap: Option, + pub manifest: Option, } impl GlobalMetadata { @@ -177,9 +219,9 @@ impl GlobalMetadata { let GlobalMetadata { favicon, robots, - sitemap, + manifest, } = self; - favicon.is_none() && robots.is_none() && sitemap.is_none() + favicon.is_none() && robots.is_none() && manifest.is_none() } } @@ -255,46 +297,6 @@ pub async fn find_app_dir_if_enabled(project_path: Vc) -> Result Ok(find_app_dir(project_path)) } -static STATIC_LOCAL_METADATA: Lazy> = - Lazy::new(|| { - HashMap::from([ - ( - "icon", - &["ico", "jpg", "jpeg", "png", "svg"] as &'static [&'static str], - ), - ("apple-icon", &["jpg", "jpeg", "png"]), - ("opengraph-image", &["jpg", "jpeg", "png", "gif"]), - ("twitter-image", &["jpg", "jpeg", "png", "gif"]), - ("favicon", &["ico"]), - ("manifest", &["webmanifest", "json"]), - ]) - }); - -static STATIC_GLOBAL_METADATA: Lazy> = - Lazy::new(|| { - HashMap::from([ - ("favicon", &["ico"] as &'static [&'static str]), - ("robots", &["txt"]), - ("sitemap", &["xml"]), - ]) - }); - -fn match_metadata_file<'a>( - basename: &'a str, - page_extensions: &[String], -) -> Option<(&'a str, i32, bool)> { - let (stem, ext) = basename.split_once('.')?; - static REGEX: Lazy = Lazy::new(|| Regex::new("^(.*?)(\\d*)$").unwrap()); - let captures = REGEX.captures(stem).expect("the regex will always match"); - let stem = captures.get(1).unwrap().as_str(); - let num: i32 = captures.get(2).unwrap().as_str().parse().unwrap_or(-1); - if page_extensions.iter().any(|e| e == ext) { - return Some((stem, num, true)); - } - let exts = STATIC_LOCAL_METADATA.get(stem)?; - exts.contains(&ext).then_some((stem, num, false)) -} - #[turbo_tasks::function] async fn get_directory_tree( dir: Vc, @@ -312,7 +314,6 @@ async fn get_directory_tree( let mut metadata_apple = Vec::new(); let mut metadata_open_graph = Vec::new(); let mut metadata_twitter = Vec::new(); - let mut metadata_favicon = Vec::new(); for (basename, entry) in entries { match *entry { @@ -328,59 +329,58 @@ async fn get_directory_tree( "not-found" => components.not_found = Some(file), "default" => components.default = Some(file), "route" => components.route = Some(file), - "manifest" => { - components.metadata.manifest = - Some(MetadataItem::Dynamic { path: file }); - continue; - } _ => {} } } } - if let Some((metadata_type, num, dynamic)) = - match_metadata_file(basename.as_str(), &page_extensions_value) - { - if metadata_type == "manifest" { - if num == -1 { - components.metadata.manifest = - Some(MetadataItem::Static { path: file }); - } - continue; - } - - let entry = match metadata_type { - "icon" => Some(&mut metadata_icon), - "apple-icon" => Some(&mut metadata_apple), - "twitter-image" => Some(&mut metadata_twitter), - "opengraph-image" => Some(&mut metadata_open_graph), - "favicon" => Some(&mut metadata_favicon), - _ => None, - }; + let Some(MetadataFileMatch { + metadata_type, + number, + dynamic, + }) = match_local_metadata_file(basename.as_str(), &page_extensions_value) + else { + continue; + }; - if let Some(entry) = entry { + let entry = match metadata_type { + "icon" => &mut metadata_icon, + "apple-icon" => &mut metadata_apple, + "twitter-image" => &mut metadata_twitter, + "opengraph-image" => &mut metadata_open_graph, + "sitemap" => { if dynamic { - entry.push((num, MetadataWithAltItem::Dynamic { path: file })); + components.metadata.sitemap = + Some(MetadataItem::Dynamic { path: file }); } else { - let file_value = file.await?; - let file_name = file_value.file_name(); - let basename = file_name - .rsplit_once('.') - .map_or(file_name, |(basename, _)| basename); - let alt_path = file.parent().join(format!("{}.alt.txt", basename)); - let alt_path = - matches!(&*alt_path.get_type().await?, FileSystemEntryType::File) - .then_some(alt_path); - entry.push(( - num, - MetadataWithAltItem::Static { - path: file, - alt_path, - }, - )); + components.metadata.sitemap = Some(MetadataItem::Static { path: file }); } + continue; } + _ => continue, + }; + + if dynamic { + entry.push((number, MetadataWithAltItem::Dynamic { path: file })); + continue; } + + let file_value = file.await?; + let file_name = file_value.file_name(); + let basename = file_name + .rsplit_once('.') + .map_or(file_name, |(basename, _)| basename); + let alt_path = file.parent().join(format!("{}.alt.txt", basename)); + let alt_path = matches!(&*alt_path.get_type().await?, FileSystemEntryType::File) + .then_some(alt_path); + + entry.push(( + number, + MetadataWithAltItem::Static { + path: file, + alt_path, + }, + )); } DirectoryEntry::Directory(dir) => { // appDir ignores paths starting with an underscore @@ -394,7 +394,7 @@ async fn get_directory_tree( } } - fn sort(mut list: Vec<(i32, T)>) -> Vec { + fn sort(mut list: Vec<(Option, T)>) -> Vec { list.sort_by_key(|(num, _)| *num); list.into_iter().map(|(_, item)| item).collect() } @@ -403,7 +403,6 @@ async fn get_directory_tree( components.metadata.apple = sort(metadata_apple); components.metadata.twitter = sort(metadata_twitter); components.metadata.open_graph = sort(metadata_open_graph); - components.metadata.favicon = sort(metadata_favicon); Ok(DirectoryTree { subdirectories, @@ -415,9 +414,11 @@ async fn get_directory_tree( #[turbo_tasks::value] #[derive(Debug, Clone)] pub struct LoaderTree { + pub page: AppPage, pub segment: String, pub parallel_routes: IndexMap>, pub components: Vc, + pub global_metadata: Vc, } #[turbo_tasks::function] @@ -443,9 +444,12 @@ async fn merge_loader_trees( let components = Components::merge(&*tree1.components.await?, &*tree2.components.await?).cell(); Ok(LoaderTree { + page: tree1.page.clone(), segment, parallel_routes, components, + // this is always the same, no need to merge it + global_metadata: tree1.global_metadata, } .cell()) } @@ -462,6 +466,10 @@ pub enum Entrypoint { page: AppPage, path: Vc, }, + AppMetadata { + page: AppPage, + metadata: MetadataItem, + }, } #[turbo_tasks::value(transparent)] @@ -568,6 +576,12 @@ async fn add_app_page( } => { conflict("route", existing_page); } + Entrypoint::AppMetadata { + page: existing_page, + .. + } => { + conflict("metadata", existing_page); + } } Ok(()) @@ -607,6 +621,55 @@ fn add_app_route( } => { conflict("route", existing_page); } + Entrypoint::AppMetadata { + page: existing_page, + .. + } => { + conflict("metadata", existing_page); + } + } +} + +fn add_app_metadata_route( + app_dir: Vc, + result: &mut IndexMap, + page: AppPage, + metadata: MetadataItem, +) { + let pathname = AppPath::from(page.clone()); + + let e = match result.entry(format!("{pathname}")) { + Entry::Occupied(e) => e, + Entry::Vacant(e) => { + e.insert(Entrypoint::AppMetadata { page, metadata }); + return; + } + }; + + let conflict = |existing_name: &str, existing_page: &AppPage| { + conflict_issue(app_dir, &e, "metadata", existing_name, &page, existing_page); + }; + + let value = e.get(); + match value { + Entrypoint::AppPage { + page: existing_page, + .. + } => { + conflict("page", existing_page); + } + Entrypoint::AppRoute { + page: existing_page, + .. + } => { + conflict("route", existing_page); + } + Entrypoint::AppMetadata { + page: existing_page, + .. + } => { + conflict("metadata", existing_page); + } } } @@ -615,20 +678,32 @@ pub fn get_entrypoints( app_dir: Vc, page_extensions: Vc>, ) -> Vc { - directory_tree_to_entrypoints(app_dir, get_directory_tree(app_dir, page_extensions)) + directory_tree_to_entrypoints( + app_dir, + get_directory_tree(app_dir, page_extensions), + get_global_metadata(app_dir, page_extensions), + ) } #[turbo_tasks::function] fn directory_tree_to_entrypoints( app_dir: Vc, directory_tree: Vc, + global_metadata: Vc, ) -> Vc { - directory_tree_to_entrypoints_internal(app_dir, "".to_string(), directory_tree, AppPage::new()) + directory_tree_to_entrypoints_internal( + app_dir, + global_metadata, + "".to_string(), + directory_tree, + AppPage::new(), + ) } #[turbo_tasks::function] async fn directory_tree_to_entrypoints_internal( app_dir: Vc, + global_metadata: Vc, directory_name: String, directory_tree: Vc, app_page: AppPage, @@ -646,9 +721,10 @@ async fn directory_tree_to_entrypoints_internal( add_app_page( app_dir, &mut result, - app_page.clone(), + app_page.clone().complete(PageType::Page)?, if current_level_is_parallel_route { LoaderTree { + page: app_page.clone(), segment: "__PAGE__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -656,13 +732,16 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell() } else { LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { "children".to_string() => LoaderTree { + page: app_page.clone(), segment: "__PAGE__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -670,10 +749,12 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell() }, @@ -685,9 +766,10 @@ async fn directory_tree_to_entrypoints_internal( add_app_page( app_dir, &mut result, - app_page.clone(), + app_page.clone().complete(PageType::Page)?, if current_level_is_parallel_route { LoaderTree { + page: app_page.clone(), segment: "__DEFAULT__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -695,24 +777,29 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell() } else { LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { - "children".to_string() => LoaderTree { - segment: "__DEFAULT__".to_string(), - parallel_routes: IndexMap::new(), - components: Components { - default: Some(default), - ..Default::default() - } - .cell(), + "children".to_string() => LoaderTree { + page: app_page.clone(), + segment: "__DEFAULT__".to_string(), + parallel_routes: IndexMap::new(), + components: Components { + default: Some(default), + ..Default::default() } .cell(), + global_metadata, + } + .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell() }, @@ -721,18 +808,67 @@ async fn directory_tree_to_entrypoints_internal( } if let Some(route) = components.route { - add_app_route(app_dir, &mut result, app_page.clone(), route); + add_app_route( + app_dir, + &mut result, + app_page.clone().complete(PageType::Route)?, + route, + ); + } + + let Metadata { + icon, + apple, + twitter, + open_graph, + sitemap, + } = &components.metadata; + + for meta in sitemap + .iter() + .copied() + .chain(icon.iter().copied().map(MetadataItem::from)) + .chain(apple.iter().copied().map(MetadataItem::from)) + .chain(twitter.iter().copied().map(MetadataItem::from)) + .chain(open_graph.iter().copied().map(MetadataItem::from)) + { + let app_page = app_page.clone_push_str(&get_metadata_route_name(meta).await?)?; + + add_app_metadata_route( + app_dir, + &mut result, + normalize_metadata_route(app_page)?, + meta, + ); } // root path: / - if app_page.len() == 0 { + if app_page.is_root() { + let GlobalMetadata { + favicon, + robots, + manifest, + } = &*global_metadata.await?; + + for meta in favicon.iter().chain(robots.iter()).chain(manifest.iter()) { + let app_page = app_page.clone_push_str(&get_metadata_route_name(*meta).await?)?; + + add_app_metadata_route( + app_dir, + &mut result, + normalize_metadata_route(app_page)?, + *meta, + ); + } // Next.js has this logic in "collect-app-paths", where the root not-found page // is considered as its own entry point. if let Some(_not_found) = components.not_found { let dev_not_found_tree = LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { "children".to_string() => LoaderTree { + page: app_page.clone(), segment: "__DEFAULT__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -740,10 +876,12 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell(); @@ -759,9 +897,11 @@ async fn directory_tree_to_entrypoints_internal( // Create default not-found page for production if there's no customized // not-found let prod_not_found_tree = LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { "children".to_string() => LoaderTree { + page: app_page.clone(), segment: "__PAGE__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -769,10 +909,12 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell(); @@ -791,9 +933,10 @@ async fn directory_tree_to_entrypoints_internal( let map = directory_tree_to_entrypoints_internal( app_dir, + global_metadata, subdir_name.to_string(), subdirectory, - app_page, + app_page.clone(), ) .await?; @@ -808,11 +951,13 @@ async fn directory_tree_to_entrypoints_internal( } else { let key = parallel_route_key.unwrap_or("children").to_string(); let child_loader_tree = LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { key => loader_tree, }, components: components.without_leafs().cell(), + global_metadata, } .cell(); add_app_page(app_dir, &mut result, page.clone(), child_loader_tree).await?; @@ -821,6 +966,9 @@ async fn directory_tree_to_entrypoints_internal( Entrypoint::AppRoute { ref page, path } => { add_app_route(app_dir, &mut result, page.clone(), path); } + Entrypoint::AppMetadata { ref page, metadata } => { + add_app_metadata_route(app_dir, &mut result, page.clone(), metadata); + } } } } @@ -845,23 +993,29 @@ pub async fn get_global_metadata( let mut metadata = GlobalMetadata::default(); for (basename, entry) in entries { - if let DirectoryEntry::File(file) = *entry { - if let Some((stem, ext)) = basename.split_once('.') { - let list = match stem { - "favicon" => Some(&mut metadata.favicon), - "sitemap" => Some(&mut metadata.sitemap), - "robots" => Some(&mut metadata.robots), - _ => None, - }; - if let Some(list) = list { - if page_extensions.await?.iter().any(|e| e == ext) { - *list = Some(MetadataItem::Dynamic { path: file }); - } - if STATIC_GLOBAL_METADATA.get(stem).unwrap().contains(&ext) { - *list = Some(MetadataItem::Static { path: file }); - } - } - } + let DirectoryEntry::File(file) = *entry else { + continue; + }; + + let Some(GlobalMetadataFileMatch { + metadata_type, + dynamic, + }) = match_global_metadata_file(basename, &page_extensions.await?) + else { + continue; + }; + + let entry = match metadata_type { + "favicon" => &mut metadata.favicon, + "manifest" => &mut metadata.manifest, + "robots" => &mut metadata.robots, + _ => continue, + }; + + if dynamic { + *entry = Some(MetadataItem::Dynamic { path: file }); + } else { + *entry = Some(MetadataItem::Static { path: file }); } // TODO(WEB-952) handle symlinks in app dir } diff --git a/packages/next-swc/crates/next-core/src/loader_tree.rs b/packages/next-swc/crates/next-core/src/loader_tree.rs index 2b4ce0f65b3ed..fc10077995bcf 100644 --- a/packages/next-swc/crates/next-core/src/loader_tree.rs +++ b/packages/next-swc/crates/next-core/src/loader_tree.rs @@ -1,3 +1,5 @@ +use std::fmt::Write; + use anyhow::Result; use async_recursion::async_recursion; use indexmap::IndexMap; @@ -12,13 +14,16 @@ use turbopack_binding::turbopack::{ reference_type::{EcmaScriptModulesReferenceSubType, InnerAssets, ReferenceType}, }, ecmascript::{magic_identifier, text::TextContentFileSource, utils::StringifyJs}, - r#static::StaticModuleAsset, turbopack::{transition::Transition, ModuleAssetContext}, }; use crate::{ - app_structure::{Components, LoaderTree, Metadata, MetadataItem, MetadataWithAltItem}, + app_structure::{ + get_metadata_route_name, Components, GlobalMetadata, LoaderTree, Metadata, MetadataItem, + MetadataWithAltItem, + }, mode::NextMode, + next_app::{metadata::image::dynamic_image_metadata_source, AppPage}, next_image::module::{BlurPlaceholderMode, StructuredImageModuleType}, }; @@ -28,7 +33,6 @@ pub struct LoaderTreeBuilder { imports: Vec, loader_tree_code: String, context: Vc, - unsupported_metadata: Vec>, mode: NextMode, server_component_transition: ServerComponentTransition, pages: Vec>, @@ -77,7 +81,6 @@ impl LoaderTreeBuilder { imports: Vec::new(), loader_tree_code: String::new(), context, - unsupported_metadata: Vec::new(), server_component_transition, mode, pages: Vec::new(), @@ -95,8 +98,6 @@ impl LoaderTreeBuilder { ty: ComponentType, component: Option>, ) -> Result<()> { - use std::fmt::Write; - if let Some(component) = component { if matches!(ty, ComponentType::Page) { self.pages.push(component); @@ -164,7 +165,12 @@ impl LoaderTreeBuilder { Ok(()) } - fn write_metadata(&mut self, metadata: &Metadata) -> Result<()> { + async fn write_metadata( + &mut self, + app_page: &AppPage, + metadata: &Metadata, + global_metadata: &GlobalMetadata, + ) -> Result<()> { if metadata.is_empty() { return Ok(()); } @@ -173,129 +179,189 @@ impl LoaderTreeBuilder { apple, twitter, open_graph, - favicon, - manifest, + sitemap: _, } = metadata; + let GlobalMetadata { + favicon: _, + manifest, + robots: _, + } = global_metadata; + self.loader_tree_code += " metadata: {"; - self.write_metadata_items("icon", favicon.iter().chain(icon.iter()))?; - self.write_metadata_items("apple", apple.iter())?; - self.write_metadata_items("twitter", twitter.iter())?; - self.write_metadata_items("openGraph", open_graph.iter())?; - self.write_metadata_manifest(*manifest)?; + self.write_metadata_items(app_page, "icon", icon.iter()) + .await?; + self.write_metadata_items(app_page, "apple", apple.iter()) + .await?; + self.write_metadata_items(app_page, "twitter", twitter.iter()) + .await?; + self.write_metadata_items(app_page, "openGraph", open_graph.iter()) + .await?; + self.write_metadata_manifest(*manifest).await?; self.loader_tree_code += " },"; Ok(()) } - fn write_metadata_manifest(&mut self, manifest: Option) -> Result<()> { + async fn write_metadata_manifest(&mut self, manifest: Option) -> Result<()> { let Some(manifest) = manifest else { return Ok(()); }; - match manifest { - MetadataItem::Static { path } => { - use std::fmt::Write; - let i = self.unique_number(); - let identifier = magic_identifier::mangle(&format!("manifest #{i}")); - let inner_module_id = format!("METADATA_{i}"); - self.imports - .push(format!("import {identifier} from \"{inner_module_id}\";")); - self.inner_assets.insert( - inner_module_id, - Vc::upcast(StaticModuleAsset::new( - Vc::upcast(FileSource::new(path)), - Vc::upcast(self.context), - )), - ); - writeln!(self.loader_tree_code, " manifest: {identifier},")?; - } - MetadataItem::Dynamic { path } => { - self.unsupported_metadata.push(path); - } - } + + let manifest_route = &format!("/{}", get_metadata_route_name(manifest).await?); + writeln!( + self.loader_tree_code, + " manifest: {},", + StringifyJs(manifest_route) + )?; Ok(()) } - fn write_metadata_items<'a>( + async fn write_metadata_items<'a>( &mut self, + app_page: &AppPage, name: &str, it: impl Iterator, ) -> Result<()> { - use std::fmt::Write; let mut it = it.peekable(); if it.peek().is_none() { return Ok(()); } writeln!(self.loader_tree_code, " {name}: [")?; for item in it { - self.write_metadata_item(name, item)?; + self.write_metadata_item(app_page, name, item).await?; } writeln!(self.loader_tree_code, " ],")?; Ok(()) } - fn write_metadata_item(&mut self, name: &str, item: &MetadataWithAltItem) -> Result<()> { - use std::fmt::Write; - let i = self.unique_number(); - let identifier = magic_identifier::mangle(&format!("{name} #{i}")); - let inner_module_id = format!("METADATA_{i}"); - self.imports - .push(format!("import {identifier} from \"{inner_module_id}\";")); - let s = " "; + async fn write_metadata_item( + &mut self, + app_page: &AppPage, + name: &str, + item: &MetadataWithAltItem, + ) -> Result<()> { match item { MetadataWithAltItem::Static { path, alt_path } => { + self.write_static_metadata_item(app_page, name, item, *path, *alt_path) + .await?; + } + MetadataWithAltItem::Dynamic { path, .. } => { + let i = self.unique_number(); + let identifier = magic_identifier::mangle(&format!("{name} #{i}")); + let inner_module_id = format!("METADATA_{i}"); + + self.imports + .push(format!("import {identifier} from \"{inner_module_id}\";")); + + let source = dynamic_image_metadata_source( + Vc::upcast(self.context), + *path, + name.to_string(), + app_page.clone(), + ); + self.inner_assets.insert( inner_module_id, - Vc::upcast(StructuredImageModuleType::create_module( - Vc::upcast(FileSource::new(*path)), - BlurPlaceholderMode::None, - self.context, - )), + self.context.process( + source, + Value::new(ReferenceType::EcmaScriptModules( + EcmaScriptModulesReferenceSubType::Undefined, + )), + ), ); - writeln!(self.loader_tree_code, "{s}(async (props) => [{{")?; - writeln!(self.loader_tree_code, "{s} url: {identifier}.src,")?; - let numeric_sizes = name == "twitter" || name == "openGraph"; - if numeric_sizes { - writeln!(self.loader_tree_code, "{s} width: {identifier}.width,")?; - writeln!(self.loader_tree_code, "{s} height: {identifier}.height,")?; - } else { - writeln!( - self.loader_tree_code, - "{s} sizes: `${{{identifier}.width}}x${{{identifier}.height}}`," - )?; - } - if let Some(alt_path) = alt_path { - let identifier = magic_identifier::mangle(&format!("{name} alt text #{i}")); - let inner_module_id = format!("METADATA_ALT_{i}"); - self.imports - .push(format!("import {identifier} from \"{inner_module_id}\";")); - self.inner_assets.insert( - inner_module_id, - self.context.process( - Vc::upcast(TextContentFileSource::new(Vc::upcast(FileSource::new( - *alt_path, - )))), - Value::new(ReferenceType::Internal(InnerAssets::empty())), - ), - ); - writeln!(self.loader_tree_code, "{s} alt: {identifier},")?; - } - writeln!(self.loader_tree_code, "{s}}}]),")?; - } - MetadataWithAltItem::Dynamic { path, .. } => { - self.unsupported_metadata.push(*path); + + let s = " "; + writeln!(self.loader_tree_code, "{s}{identifier},")?; } } Ok(()) } + async fn write_static_metadata_item( + &mut self, + app_page: &AppPage, + name: &str, + item: &MetadataWithAltItem, + path: Vc, + alt_path: Option>, + ) -> Result<()> { + let i = self.unique_number(); + let identifier = magic_identifier::mangle(&format!("{name} #{i}")); + let inner_module_id = format!("METADATA_{i}"); + let helper_import = "import { fillMetadataSegment } from \ + \"next/dist/lib/metadata/get-metadata-route\"" + .to_string(); + if !self.imports.contains(&helper_import) { + self.imports.push(helper_import); + } + + self.imports + .push(format!("import {identifier} from \"{inner_module_id}\";")); + self.inner_assets.insert( + inner_module_id, + Vc::upcast(StructuredImageModuleType::create_module( + Vc::upcast(FileSource::new(path)), + BlurPlaceholderMode::None, + self.context, + )), + ); + + let s = " "; + writeln!(self.loader_tree_code, "{s}(async (props) => [{{")?; + + let metadata_route = &*get_metadata_route_name((*item).into()).await?; + writeln!( + self.loader_tree_code, + "{s} url: fillMetadataSegment({}, props.params, {}) + \ + `?${{{identifier}.src.split(\"/\").splice(-1)[0]}}`,", + StringifyJs(&app_page.to_string()), + StringifyJs(metadata_route), + )?; + + let numeric_sizes = name == "twitter" || name == "openGraph"; + if numeric_sizes { + writeln!(self.loader_tree_code, "{s} width: {identifier}.width,")?; + writeln!(self.loader_tree_code, "{s} height: {identifier}.height,")?; + } else { + writeln!( + self.loader_tree_code, + "{s} sizes: `${{{identifier}.width}}x${{{identifier}.height}}`," + )?; + } + + if let Some(alt_path) = alt_path { + let identifier = magic_identifier::mangle(&format!("{name} alt text #{i}")); + let inner_module_id = format!("METADATA_ALT_{i}"); + self.imports + .push(format!("import {identifier} from \"{inner_module_id}\";")); + self.inner_assets.insert( + inner_module_id, + self.context.process( + Vc::upcast(TextContentFileSource::new(Vc::upcast(FileSource::new( + alt_path, + )))), + Value::new(ReferenceType::Internal(InnerAssets::empty())), + ), + ); + + writeln!(self.loader_tree_code, "{s} alt: {identifier},")?; + } + + writeln!(self.loader_tree_code, "{s}}}]),")?; + + Ok(()) + } + #[async_recursion] async fn walk_tree(&mut self, loader_tree: Vc) -> Result<()> { use std::fmt::Write; let LoaderTree { + page: app_page, segment, parallel_routes, components, + global_metadata, } = &*loader_tree.await?; writeln!( @@ -333,7 +399,8 @@ impl LoaderTreeBuilder { .await?; self.write_component(ComponentType::NotFound, *not_found) .await?; - self.write_metadata(metadata)?; + self.write_metadata(app_page, metadata, &*global_metadata.await?) + .await?; write!(self.loader_tree_code, "}}]")?; Ok(()) } @@ -344,7 +411,6 @@ impl LoaderTreeBuilder { imports: self.imports, loader_tree_code: self.loader_tree_code, inner_assets: self.inner_assets, - unsupported_metadata: self.unsupported_metadata, pages: self.pages, }) } @@ -354,7 +420,6 @@ pub struct LoaderTreeModule { pub imports: Vec, pub loader_tree_code: String, pub inner_assets: IndexMap>>, - pub unsupported_metadata: Vec>, pub pages: Vec>, } diff --git a/packages/next-swc/crates/next-core/src/mode.rs b/packages/next-swc/crates/next-core/src/mode.rs index ab54af0391f05..db3c94a6c5542 100644 --- a/packages/next-swc/crates/next-core/src/mode.rs +++ b/packages/next-swc/crates/next-core/src/mode.rs @@ -1,22 +1,8 @@ -use serde::{Deserialize, Serialize}; -use turbo_tasks::{debug::ValueDebugFormat, trace::TraceRawVcs, TaskInput}; +use turbo_tasks::TaskInput; /// The mode in which Next.js is running. -#[derive( - Debug, - Copy, - Clone, - TaskInput, - Eq, - PartialEq, - Ord, - PartialOrd, - Hash, - Serialize, - Deserialize, - TraceRawVcs, - ValueDebugFormat, -)] +#[turbo_tasks::value(shared)] +#[derive(Debug, Copy, Clone, TaskInput, Ord, PartialOrd, Hash)] pub enum NextMode { /// `next dev --turbo` DevServer, diff --git a/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs b/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs index fd99d25664678..e41c15bd1169e 100644 --- a/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs +++ b/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs @@ -6,8 +6,8 @@ use turbopack_binding::{ turbo::tasks_fs::{rope::RopeBuilder, File, FileSystemPath}, turbopack::{ core::{ - asset::AssetContent, context::AssetContext, issue::IssueExt, - reference_type::ReferenceType, virtual_source::VirtualSource, + asset::AssetContent, context::AssetContext, reference_type::ReferenceType, + virtual_source::VirtualSource, }, ecmascript::{chunk::EcmascriptChunkPlaceable, utils::StringifyJs}, turbopack::ModuleAssetContext, @@ -19,7 +19,7 @@ use crate::{ app_structure::LoaderTree, loader_tree::{LoaderTreeModule, ServerComponentTransition}, mode::NextMode, - next_app::{AppPage, AppPath, UnsupportedDynamicMetadataIssue}, + next_app::{AppPage, AppPath}, next_server_component::NextServerComponentTransition, parse_segment_config_from_loader_tree, util::{load_next_js_template, virtual_next_js_template_path, NextRuntime}, @@ -31,7 +31,6 @@ pub async fn get_app_page_entry( nodejs_context: Vc, edge_context: Vc, loader_tree: Vc, - app_dir: Vc, page: AppPage, project_root: Vc, ) -> Result> { @@ -57,19 +56,9 @@ pub async fn get_app_page_entry( inner_assets, imports, loader_tree_code, - unsupported_metadata, pages, } = loader_tree; - if !unsupported_metadata.is_empty() { - UnsupportedDynamicMetadataIssue { - app_dir, - files: unsupported_metadata, - } - .cell() - .emit(); - } - let mut result = RopeBuilder::default(); for import in imports { @@ -81,8 +70,6 @@ pub async fn get_app_page_entry( let original_name = page.to_string(); let pathname = AppPath::from(page.clone()).to_string(); - let original_page_name = get_original_page_name(&original_name); - let template_file = "build/templates/app-page.js"; // Load the file from the next.js codebase. @@ -100,7 +87,7 @@ pub async fn get_app_page_entry( ) .replace( "\"VAR_ORIGINAL_PATHNAME\"", - &StringifyJs(&original_page_name).to_string(), + &StringifyJs(&original_name).to_string(), ) // TODO(alexkirsz) Support custom global error. .replace( @@ -154,20 +141,9 @@ pub async fn get_app_page_entry( Ok(AppEntry { pathname: pathname.to_string(), - original_name: original_page_name, + original_name, rsc_entry, config, } .cell()) } - -// TODO(alexkirsz) This shouldn't be necessary. The loader tree should keep -// track of this instead. -fn get_original_page_name(pathname: &str) -> String { - match pathname { - "/" => "/page".to_string(), - "/_not-found" => "/_not-found".to_string(), - "/not-found" => "/not-found".to_string(), - _ => format!("{}/page", pathname), - } -} diff --git a/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs b/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs index e60c963e0ece0..838d92dc90036 100644 --- a/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs +++ b/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs @@ -54,7 +54,6 @@ pub async fn get_app_route_entry( let original_name = page.to_string(); let pathname = AppPath::from(page.clone()).to_string(); - let original_page_name = get_original_route_name(&original_name); let path = source.ident().path(); let template_file = "build/templates/app-route.js"; @@ -83,7 +82,7 @@ pub async fn get_app_route_entry( ) .replace( "\"VAR_ORIGINAL_PATHNAME\"", - &StringifyJs(&original_page_name).to_string(), + &StringifyJs(&original_name).to_string(), ) .replace( "\"VAR_RESOLVED_PAGE_PATH\"", @@ -132,8 +131,8 @@ pub async fn get_app_route_entry( }; Ok(AppEntry { - pathname: pathname.to_string(), - original_name: original_page_name, + pathname, + original_name, rsc_entry, config, } @@ -177,10 +176,3 @@ pub async fn wrap_edge_entry( Value::new(ReferenceType::Internal(Vc::cell(inner_assets))), )) } - -fn get_original_route_name(pathname: &str) -> String { - match pathname { - "/" => "/route".to_string(), - _ => format!("{}/route", pathname), - } -} diff --git a/packages/next-swc/crates/next-core/src/next_app/metadata/image.rs b/packages/next-swc/crates/next-core/src/next_app/metadata/image.rs new file mode 100644 index 0000000000000..68e8a7c6677dd --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_app/metadata/image.rs @@ -0,0 +1,148 @@ +//! (partial) Rust port of the `next-metadata-image-loader` +//! +//! See `next/src/build/webpack/loaders/next-metadata-image-loader` + +use anyhow::{bail, Result}; +use indoc::formatdoc; +use turbo_tasks::{ValueToString, Vc}; +use turbo_tasks_fs::{File, FileContent, FileSystemPath}; +use turbopack_binding::{ + turbo::tasks_hash::hash_xxh3_hash64, + turbopack::{ + core::{ + asset::AssetContent, + context::AssetContext, + file_source::FileSource, + module::Module, + reference_type::{EcmaScriptModulesReferenceSubType, ReferenceType}, + source::Source, + virtual_source::VirtualSource, + }, + ecmascript::{ + chunk::{EcmascriptChunkPlaceable, EcmascriptExports}, + utils::StringifyJs, + EcmascriptModuleAsset, + }, + }, +}; + +use crate::next_app::AppPage; + +async fn hash_file_content(path: Vc) -> Result { + let original_file_content = path.read().await?; + + Ok(match &*original_file_content { + FileContent::Content(content) => { + let content = content.content().to_bytes()?; + hash_xxh3_hash64(&*content) + } + FileContent::NotFound => { + bail!("metadata file not found: {}", &path.to_string().await?); + } + }) +} + +#[turbo_tasks::function] +pub async fn dynamic_image_metadata_source( + asset_context: Vc>, + path: Vc, + ty: String, + page: AppPage, +) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let hash_query = format!("?{:x}", hash_file_content(path).await?); + + let use_numeric_sizes = ty == "twitter" || ty == "openGraph"; + let sizes = if use_numeric_sizes { + "data.width = size.width; data.height = size.height;" + } else { + "data.sizes = size.width + \"x\" + size.height;" + }; + + let source = Vc::upcast(FileSource::new(path)); + let exports = &*collect_direct_exports(asset_context.process( + source, + turbo_tasks::Value::new(ReferenceType::EcmaScriptModules( + EcmaScriptModulesReferenceSubType::Undefined, + )), + )) + .await?; + let exported_fields_excluding_default = exports + .iter() + .filter(|e| *e != "default") + .cloned() + .collect::>() + .join(", "); + + let code = formatdoc! { + r#" + import {{ {exported_fields_excluding_default} }} from {resource_path} + import {{ fillMetadataSegment }} from 'next/dist/lib/metadata/get-metadata-route' + + const imageModule = {{ {exported_fields_excluding_default} }} + + export default async function (props) {{ + const {{ __metadata_id__: _, ...params }} = props.params + const imageUrl = fillMetadataSegment({pathname_prefix}, params, {page_segment}) + + const {{ generateImageMetadata }} = imageModule + + function getImageMetadata(imageMetadata, idParam) {{ + const data = {{ + alt: imageMetadata.alt, + type: imageMetadata.contentType || 'image/png', + url: imageUrl + (idParam ? ('/' + idParam) : '') + {hash_query}, + }} + const {{ size }} = imageMetadata + if (size) {{ + {sizes} + }} + return data + }} + + if (generateImageMetadata) {{ + const imageMetadataArray = await generateImageMetadata({{ params }}) + return imageMetadataArray.map((imageMetadata, index) => {{ + const idParam = (imageMetadata.id || index) + '' + return getImageMetadata(imageMetadata, idParam) + }}) + }} else {{ + return [getImageMetadata(imageModule, '')] + }} + }} + "#, + exported_fields_excluding_default = exported_fields_excluding_default, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + pathname_prefix = StringifyJs(&page.to_string()), + page_segment = StringifyJs(stem), + sizes = sizes, + hash_query = StringifyJs(&hash_query), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--metadata.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn collect_direct_exports(module: Vc>) -> Result>> { + let Some(ecmascript_asset) = + Vc::try_resolve_downcast_type::(module).await? + else { + return Ok(Default::default()); + }; + + if let EcmascriptExports::EsmExports(exports) = &*ecmascript_asset.get_exports().await? { + let exports = &*exports.await?; + return Ok(Vc::cell(exports.exports.keys().cloned().collect())); + } + + Ok(Vc::cell(Vec::new())) +} diff --git a/packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs b/packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs new file mode 100644 index 0000000000000..e0c86c172f543 --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs @@ -0,0 +1,341 @@ +use std::{collections::HashMap, ops::Deref}; + +use anyhow::Result; +use once_cell::sync::Lazy; + +use crate::next_app::{AppPage, PageSegment, PageType}; + +pub mod image; +pub mod route; + +pub static STATIC_LOCAL_METADATA: Lazy> = + Lazy::new(|| { + HashMap::from([ + ( + "icon", + &["ico", "jpg", "jpeg", "png", "svg"] as &'static [&'static str], + ), + ("apple-icon", &["jpg", "jpeg", "png"]), + ("opengraph-image", &["jpg", "jpeg", "png", "gif"]), + ("twitter-image", &["jpg", "jpeg", "png", "gif"]), + ("sitemap", &["xml"]), + ]) + }); + +pub static STATIC_GLOBAL_METADATA: Lazy> = + Lazy::new(|| { + HashMap::from([ + ("favicon", &["ico"] as &'static [&'static str]), + ("manifest", &["webmanifest", "json"]), + ("robots", &["txt"]), + ]) + }); + +pub struct MetadataFileMatch<'a> { + pub metadata_type: &'a str, + pub number: Option, + pub dynamic: bool, +} + +fn match_numbered_metadata(stem: &str) -> Option<(&str, &str)> { + let (_whole, stem, number) = lazy_regex::regex_captures!( + "^(icon|apple-icon|opengraph-image|twitter-image)(\\d+)$", + stem + )?; + + Some((stem, number)) +} + +fn match_metadata_file<'a>( + filename: &'a str, + page_extensions: &[String], + metadata: &HashMap<&str, &[&str]>, +) -> Option> { + let (stem, ext) = filename.split_once('.')?; + + let (stem, number) = match match_numbered_metadata(stem) { + Some((stem, number)) => { + let number: u32 = number.parse().ok()?; + (stem, Some(number)) + } + _ => (stem, None), + }; + + let exts = metadata.get(stem)?; + + // favicon can't be dynamic + if stem != "favicon" && page_extensions.iter().any(|e| e == ext) { + return Some(MetadataFileMatch { + metadata_type: stem, + number, + dynamic: true, + }); + } + + exts.contains(&ext).then_some(MetadataFileMatch { + metadata_type: stem, + number, + dynamic: false, + }) +} + +pub fn match_local_metadata_file<'a>( + basename: &'a str, + page_extensions: &[String], +) -> Option> { + match_metadata_file(basename, page_extensions, STATIC_LOCAL_METADATA.deref()) +} + +pub struct GlobalMetadataFileMatch<'a> { + pub metadata_type: &'a str, + pub dynamic: bool, +} + +pub fn match_global_metadata_file<'a>( + basename: &'a str, + page_extensions: &[String], +) -> Option> { + match_metadata_file(basename, page_extensions, STATIC_GLOBAL_METADATA.deref()).map(|m| { + GlobalMetadataFileMatch { + metadata_type: m.metadata_type, + dynamic: m.dynamic, + } + }) +} + +fn split_directory(path: &str) -> (Option<&str>, &str) { + if let Some((dir, basename)) = path.rsplit_once('/') { + if dir.is_empty() { + return (Some("/"), basename); + } + + (Some(dir), basename) + } else { + (None, path) + } +} + +fn filename(path: &str) -> &str { + split_directory(path).1 +} + +fn split_extension(path: &str) -> (&str, Option<&str>) { + let filename = filename(path); + if let Some((filename_before_extension, ext)) = filename.rsplit_once('.') { + if filename_before_extension.is_empty() { + return (filename, None); + } + + (filename_before_extension, Some(ext)) + } else { + (filename, None) + } +} + +fn file_stem(path: &str) -> &str { + split_extension(path).0 +} + +/// When you only pass the file extension as `[]`, it will only match the static +/// convention files e.g. `/robots.txt`, `/sitemap.xml`, `/favicon.ico`, +/// `/manifest.json`. +/// +/// When you pass the file extension as `['js', 'jsx', 'ts', +/// 'tsx']`, it will also match the dynamic convention files e.g. /robots.js, +/// /sitemap.tsx, /favicon.jsx, /manifest.ts. +/// +/// When `withExtension` is false, it will match the static convention files +/// without the extension, by default it's true e.g. /robots, /sitemap, +/// /favicon, /manifest, use to match dynamic API routes like app/robots.ts. +pub fn is_metadata_route_file( + app_dir_relative_path: &str, + page_extensions: &[String], + with_extension: bool, +) -> bool { + let (dir, filename) = split_directory(app_dir_relative_path); + + if with_extension { + if match_local_metadata_file(filename, page_extensions).is_some() { + return true; + } + } else { + let stem = file_stem(filename); + let stem = match_numbered_metadata(stem) + .map(|(stem, _)| stem) + .unwrap_or(stem); + + if STATIC_LOCAL_METADATA.contains_key(stem) { + return true; + } + } + + if dir != Some("/") { + return false; + } + + if with_extension { + if match_global_metadata_file(filename, page_extensions).is_some() { + return true; + } + } else { + let base_name = file_stem(filename); + if STATIC_GLOBAL_METADATA.contains_key(base_name) { + return true; + } + } + + false +} + +pub fn is_static_metadata_route_file(app_dir_relative_path: &str) -> bool { + is_metadata_route_file(app_dir_relative_path, &[], true) +} + +/// Remove the 'app' prefix or '/route' suffix, only check the route name since +/// they're only allowed in root app directory +/// +/// e.g. +/// - /app/robots -> /robots +/// - app/robots -> /robots +/// - /robots -> /robots +pub fn is_metadata_route(mut route: &str) -> bool { + if let Some(stripped) = route.strip_prefix("/app/") { + route = stripped; + } else if let Some(stripped) = route.strip_prefix("app/") { + route = stripped; + } + + if let Some(stripped) = route.strip_suffix("/route") { + route = stripped; + } + + let mut page = route.to_string(); + if !page.starts_with('/') { + page = format!("/{}", page); + } + + !page.ends_with("/page") && is_metadata_route_file(&page, &[], false) +} + +/// http://www.cse.yorku.ca/~oz/hash.html +fn djb2_hash(str: &str) -> u32 { + str.chars().fold(5381, |hash, c| { + ((hash << 5).wrapping_add(hash)).wrapping_add(c as u32) // hash * 33 + c + }) +} + +// this is here to mirror next.js behaviour. +fn format_radix(mut x: u32, radix: u32) -> String { + let mut result = vec![]; + + loop { + let m = x % radix; + x /= radix; + + // will panic if you use a bad radix (< 2 or > 36). + result.push(std::char::from_digit(m, radix).unwrap()); + if x == 0 { + break; + } + } + + result.into_iter().rev().collect() +} + +/// If there's special convention like (...) or @ in the page path, +/// Give it a unique hash suffix to avoid conflicts +/// +/// e.g. +/// /app/open-graph.tsx -> /open-graph/route +/// /app/(post)/open-graph.tsx -> /open-graph/route-[0-9a-z]{6} +fn get_metadata_route_suffix(page: &str) -> Option { + if (page.contains('(') && page.contains(')')) || page.contains('@') { + Some(format_radix(djb2_hash(page), 36)) + } else { + None + } +} + +/// Map metadata page key to the corresponding route +/// +/// static file page key: /app/robots.txt -> /robots.txt -> /robots.txt/route +/// dynamic route page key: /app/robots.tsx -> /robots -> /robots.txt/route +pub fn normalize_metadata_route(mut page: AppPage) -> Result { + if !is_metadata_route(&format!("{page}")) { + return Ok(page); + } + + let mut route = page.to_string(); + let mut suffix: Option = None; + if route == "/robots" { + route += ".txt" + } else if route == "/manifest" { + route += ".webmanifest" + } else if route.ends_with("/sitemap") { + route += ".xml" + } else { + // Remove the file extension, e.g. /route-path/robots.txt -> /route-path + let pathname_prefix = split_directory(&route).0.unwrap_or_default(); + suffix = get_metadata_route_suffix(pathname_prefix); + } + + // Support both / and custom routes + // //route.ts. If it's a metadata file route, we need to + // append /[id]/route to the page. + if !route.ends_with("/route") { + let is_static_metadata_file = is_static_metadata_route_file(&route); + let (base_name, ext) = split_extension(&route); + + let is_static_route = route.starts_with("/robots") + || route.starts_with("/manifest") + || is_static_metadata_file; + + page.0.pop(); + + page.push(PageSegment::Static(format!( + "{}{}{}", + base_name, + suffix + .map(|suffix| format!("-{suffix}")) + .unwrap_or_default(), + ext.map(|ext| format!(".{ext}")).unwrap_or_default(), + )))?; + + if !is_static_route { + page.push(PageSegment::OptionalCatchAll("__metadata_id__".to_string()))?; + } + + page.push(PageSegment::PageType(PageType::Route))?; + } + + Ok(page) +} + +#[cfg(test)] +mod test { + use super::normalize_metadata_route; + use crate::next_app::AppPage; + + #[test] + fn test_normalize_metadata_route() { + let cases = vec![ + [ + "/client/(meme)/more-route/twitter-image", + "/client/(meme)/more-route/twitter-image-769mad/[[...__metadata_id__]]/route", + ], + [ + "/client/(meme)/more-route/twitter-image2", + "/client/(meme)/more-route/twitter-image2-769mad/[[...__metadata_id__]]/route", + ], + ["/robots.txt", "/robots.txt/route"], + ["/manifest.webmanifest", "/manifest.webmanifest/route"], + ]; + + for [input, expected] in cases { + let page = AppPage::parse(input).unwrap(); + let normalized = normalize_metadata_route(page).unwrap(); + + assert_eq!(&normalized.to_string(), expected); + } + } +} diff --git a/packages/next-swc/crates/next-core/src/next_app/metadata/route.rs b/packages/next-swc/crates/next-core/src/next_app/metadata/route.rs new file mode 100644 index 0000000000000..b3782ad60cbc2 --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_app/metadata/route.rs @@ -0,0 +1,364 @@ +//! Rust port of the `next-metadata-route-loader` +//! +//! See `next/src/build/webpack/loaders/next-metadata-route-loader` + +use anyhow::{bail, Result}; +use base64::{display::Base64Display, engine::general_purpose::STANDARD}; +use indoc::{formatdoc, indoc}; +use turbo_tasks::{ValueToString, Vc}; +use turbopack_binding::{ + turbo::tasks_fs::{File, FileContent, FileSystemPath}, + turbopack::{ + core::{asset::AssetContent, source::Source, virtual_source::VirtualSource}, + ecmascript::utils::StringifyJs, + turbopack::ModuleAssetContext, + }, +}; + +use crate::{ + app_structure::MetadataItem, + mode::NextMode, + next_app::{app_entry::AppEntry, app_route_entry::get_app_route_entry, AppPage, PageSegment}, +}; + +/// Computes the route source for a Next.js metadata file. +#[turbo_tasks::function] +pub async fn get_app_metadata_route_source( + page: AppPage, + mode: NextMode, + metadata: MetadataItem, +) -> Result>> { + Ok(match metadata { + MetadataItem::Static { path } => static_route_source(mode, path), + MetadataItem::Dynamic { path } => { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + + if stem == "robots" || stem == "manifest" { + dynamic_text_route_source(path) + } else if stem == "sitemap" { + dynamic_site_map_route_source(mode, path, page) + } else { + dynamic_image_route_source(path) + } + } + }) +} + +#[turbo_tasks::function] +pub fn get_app_metadata_route_entry( + nodejs_context: Vc, + edge_context: Vc, + project_root: Vc, + page: AppPage, + mode: NextMode, + metadata: MetadataItem, +) -> Vc { + get_app_route_entry( + nodejs_context, + edge_context, + get_app_metadata_route_source(page.clone(), mode, metadata), + page, + project_root, + ) +} + +async fn get_content_type(path: Vc) -> Result { + let stem = &*path.file_stem().await?; + let ext = &*path.extension().await?; + + let name = stem.as_deref().unwrap_or_default(); + let mut ext = ext.as_str(); + if ext == "jpg" { + ext = "jpeg" + } + + if name == "favicon" && ext == "ico" { + return Ok("image/x-icon".to_string()); + } + if name == "sitemap" { + return Ok("application/xml".to_string()); + } + if name == "robots" { + return Ok("text/plain".to_string()); + } + if name == "manifest" { + return Ok("application/manifest+json".to_string()); + } + + if ext == "png" || ext == "jpeg" || ext == "ico" || ext == "svg" { + return Ok(mime_guess::from_ext(ext) + .first_or_octet_stream() + .to_string()); + } + + Ok("text/plain".to_string()) +} + +const CACHE_HEADER_NONE: &str = "no-cache, no-store"; +const CACHE_HEADER_LONG_CACHE: &str = "public, immutable, no-transform, max-age=31536000"; +const CACHE_HEADER_REVALIDATE: &str = "public, max-age=0, must-revalidate"; + +async fn get_base64_file_content(path: Vc) -> Result { + let original_file_content = path.read().await?; + + Ok(match &*original_file_content { + FileContent::Content(content) => { + let content = content.content().to_bytes()?; + Base64Display::new(&content, &STANDARD).to_string() + } + FileContent::NotFound => { + bail!("metadata file not found: {}", &path.to_string().await?); + } + }) +} + +#[turbo_tasks::function] +async fn static_route_source( + mode: NextMode, + path: Vc, +) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + + let content_type = get_content_type(path).await?; + + let cache_control = if stem == "favicon" { + CACHE_HEADER_REVALIDATE + } else if mode == NextMode::Build { + CACHE_HEADER_LONG_CACHE + } else { + CACHE_HEADER_NONE + }; + + let original_file_content_b64 = get_base64_file_content(path).await?; + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + + const contentType = {content_type} + const cacheControl = {cache_control} + const buffer = Buffer.from({original_file_content_b64}, 'base64') + + export function GET() {{ + return new NextResponse(buffer, {{ + headers: {{ + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + }}, + }}) + }} + + export const dynamic = 'force-static' + "#, + content_type = StringifyJs(&content_type), + cache_control = StringifyJs(cache_control), + original_file_content_b64 = StringifyJs(&original_file_content_b64), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn dynamic_text_route_source(path: Vc) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let content_type = get_content_type(path).await?; + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + import handler from {resource_path} + import {{ resolveRouteData }} from +'next/dist/build/webpack/loaders/metadata/resolve-route-data' + + const contentType = {content_type} + const cacheControl = {cache_control} + const fileType = {file_type} + + export async function GET() {{ + const data = await handler() + const content = resolveRouteData(data, fileType) + + return new NextResponse(content, {{ + headers: {{ + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + }}, + }}) + }} + "#, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + content_type = StringifyJs(&content_type), + file_type = StringifyJs(&stem), + cache_control = StringifyJs(CACHE_HEADER_REVALIDATE), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn dynamic_site_map_route_source( + mode: NextMode, + path: Vc, + page: AppPage, +) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let content_type = get_content_type(path).await?; + + let mut static_generation_code = ""; + + if mode == NextMode::Build + && page.contains(&PageSegment::Dynamic("[__metadata_id__]".to_string())) + { + static_generation_code = indoc! { + r#" + export async function generateStaticParams() { + const sitemaps = await generateSitemaps() + const params = [] + + for (const item of sitemaps) { + params.push({ __metadata_id__: item.id.toString() + '.xml' }) + } + return params + } + "#, + }; + } + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + import * as _sitemapModule from {resource_path} + import {{ resolveRouteData }} from 'next/dist/build/webpack/loaders/metadata/resolve-route-data' + + const sitemapModule = {{ ..._sitemapModule }} + const handler = sitemapModule.default + const generateSitemaps = sitemapModule.generateSitemaps + const contentType = {content_type} + const cacheControl = {cache_control} + const fileType = {file_type} + + export async function GET(_, ctx) {{ + const {{ __metadata_id__ = [], ...params }} = ctx.params || {{}} + const targetId = __metadata_id__[0] + let id = undefined + const sitemaps = generateSitemaps ? await generateSitemaps() : null + + if (sitemaps) {{ + id = sitemaps.find((item) => {{ + if (process.env.NODE_ENV !== 'production') {{ + if (item?.id == null) {{ + throw new Error('id property is required for every item returned from generateSitemaps') + }} + }} + return item.id.toString() === targetId + }})?.id + + if (id == null) {{ + return new NextResponse('Not Found', {{ + status: 404, + }}) + }} + }} + + const data = await handler({{ id }}) + const content = resolveRouteData(data, fileType) + + return new NextResponse(content, {{ + headers: {{ + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + }}, + }}) + }} + + {static_generation_code} + "#, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + content_type = StringifyJs(&content_type), + file_type = StringifyJs(&stem), + cache_control = StringifyJs(CACHE_HEADER_REVALIDATE), + static_generation_code = static_generation_code, + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn dynamic_image_route_source(path: Vc) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + import * as _imageModule from {resource_path} + + const imageModule = {{ ..._imageModule }} + + const handler = imageModule.default + const generateImageMetadata = imageModule.generateImageMetadata + + export async function GET(_, ctx) {{ + const {{ __metadata_id__ = [], ...params }} = ctx.params || {{}} + const targetId = __metadata_id__[0] + let id = undefined + const imageMetadata = generateImageMetadata ? await generateImageMetadata({{ params }}) : null + + if (imageMetadata) {{ + id = imageMetadata.find((item) => {{ + if (process.env.NODE_ENV !== 'production') {{ + if (item?.id == null) {{ + throw new Error('id property is required for every item returned from generateImageMetadata') + }} + }} + return item.id.toString() === targetId + }})?.id + + if (id == null) {{ + return new NextResponse('Not Found', {{ + status: 404, + }}) + }} + }} + + return handler({{ params: ctx.params ? params : undefined, id }}) + }} + "#, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} diff --git a/packages/next-swc/crates/next-core/src/next_app/mod.rs b/packages/next-swc/crates/next-core/src/next_app/mod.rs index 885ecdf4cd715..e43e5fa35e80e 100644 --- a/packages/next-swc/crates/next-core/src/next_app/mod.rs +++ b/packages/next-swc/crates/next-core/src/next_app/mod.rs @@ -1,10 +1,9 @@ -pub(crate) mod app_client_references_chunks; -pub(crate) mod app_client_shared_chunks; -pub(crate) mod app_entry; -pub(crate) mod app_favicon_entry; -pub(crate) mod app_page_entry; -pub(crate) mod app_route_entry; -pub(crate) mod unsupported_dynamic_metadata_issue; +pub mod app_client_references_chunks; +pub mod app_client_shared_chunks; +pub mod app_entry; +pub mod app_page_entry; +pub mod app_route_entry; +pub mod metadata; use std::{ fmt::{Display, Formatter, Write}, @@ -21,20 +20,27 @@ pub use crate::next_app::{ }, app_client_shared_chunks::get_app_client_shared_chunks, app_entry::AppEntry, - app_favicon_entry::get_app_route_favicon_entry, app_page_entry::get_app_page_entry, app_route_entry::get_app_route_entry, - unsupported_dynamic_metadata_issue::UnsupportedDynamicMetadataIssue, }; +/// See [AppPage]. #[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq, TaskInput, TraceRawVcs)] pub enum PageSegment { + /// e.g. `/dashboard` Static(String), + /// e.g. `/[id]` Dynamic(String), + /// e.g. `/[...slug]` CatchAll(String), + /// e.g. `/[[...slug]]` OptionalCatchAll(String), + /// e.g. `/(shop)` Group(String), + /// e.g. `/@auth` Parallel(String), + /// The final page type appended. (e.g. `/dashboard/page`, + /// `/api/hello/route`) PageType(PageType), } @@ -151,6 +157,13 @@ impl AppPage { ) } + if self.is_complete() { + bail!( + "Invalid segment {}, this page path already has the final PageType appended", + segment + ) + } + self.0.push(segment); Ok(()) } @@ -184,6 +197,18 @@ impl AppPage { Ok(app_page) } + + pub fn is_root(&self) -> bool { + self.0.is_empty() + } + + pub fn is_complete(&self) -> bool { + matches!(self.0.last(), Some(PageSegment::PageType(..))) + } + + pub fn complete(self, page_type: PageType) -> Result { + self.clone_push(PageSegment::PageType(page_type)) + } } impl Display for AppPage { @@ -209,11 +234,18 @@ impl Deref for AppPage { } } +/// Path segments for a router path (not including parallel routes and groups). +/// +/// Also see [AppPath]. #[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq, TaskInput, TraceRawVcs)] pub enum PathSegment { + /// e.g. `/dashboard` Static(String), + /// e.g. `/[id]` Dynamic(String), + /// e.g. `/[...slug]` CatchAll(String), + /// e.g. `/[[...slug]]` OptionalCatchAll(String), } @@ -240,7 +272,11 @@ impl Display for PathSegment { } } -/// The pathname (including dynamic placeholders) for a route to resolve. +/// The pathname (including dynamic placeholders) for the next.js router to +/// resolve. +/// +/// Does not include internal modifiers as it's the equivalent of the http +/// request path. #[derive( Clone, Debug, Hash, PartialEq, Eq, Default, Serialize, Deserialize, TaskInput, TraceRawVcs, )] diff --git a/packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs b/packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs deleted file mode 100644 index 3c5fc2977c4f7..0000000000000 --- a/packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs +++ /dev/null @@ -1,54 +0,0 @@ -use anyhow::Result; -use turbo_tasks::{TryJoinIterExt, ValueToString, Vc}; -use turbo_tasks_fs::FileSystemPath; -use turbopack_binding::turbopack::{ - core::issue::{Issue, IssueSeverity}, - ecmascript::utils::FormatIter, -}; - -#[turbo_tasks::value(shared)] -pub struct UnsupportedDynamicMetadataIssue { - pub app_dir: Vc, - pub files: Vec>, -} - -#[turbo_tasks::value_impl] -impl Issue for UnsupportedDynamicMetadataIssue { - #[turbo_tasks::function] - fn severity(&self) -> Vc { - IssueSeverity::Warning.into() - } - - #[turbo_tasks::function] - fn category(&self) -> Vc { - Vc::cell("unsupported".to_string()) - } - - #[turbo_tasks::function] - fn file_path(&self) -> Vc { - self.app_dir - } - - #[turbo_tasks::function] - fn title(&self) -> Vc { - Vc::cell( - "Dynamic metadata from filesystem is currently not supported in Turbopack".to_string(), - ) - } - - #[turbo_tasks::function] - async fn description(&self) -> Result> { - let mut files = self - .files - .iter() - .map(|file| file.to_string()) - .try_join() - .await?; - files.sort(); - Ok(Vc::cell(format!( - "The following files were found in the app directory, but are not supported by \ - Turbopack. They are ignored:\n{}", - FormatIter(|| files.iter().flat_map(|file| vec!["\n- ", file])) - ))) - } -} diff --git a/packages/next-swc/crates/next-core/src/next_import_map.rs b/packages/next-swc/crates/next-core/src/next_import_map.rs index c530f3bcb9290..87ef801b78415 100644 --- a/packages/next-swc/crates/next-core/src/next_import_map.rs +++ b/packages/next-swc/crates/next-core/src/next_import_map.rs @@ -215,7 +215,14 @@ pub async fn get_next_server_import_map( let ty = ty.into_value(); - insert_next_server_special_aliases(&mut import_map, ty, mode, NextRuntime::NodeJs).await?; + insert_next_server_special_aliases( + &mut import_map, + project_path, + ty, + mode, + NextRuntime::NodeJs, + ) + .await?; let external: Vc = ImportMapping::External(None).cell(); import_map.insert_exact_alias("next/dist/server/require-hook", external); @@ -290,7 +297,8 @@ pub async fn get_next_edge_import_map( let ty = ty.into_value(); - insert_next_server_special_aliases(&mut import_map, ty, mode, NextRuntime::Edge).await?; + insert_next_server_special_aliases(&mut import_map, project_path, ty, mode, NextRuntime::Edge) + .await?; match ty { ServerContextType::Pages { .. } | ServerContextType::PagesData { .. } => {} @@ -371,6 +379,7 @@ static NEXT_ALIASES: [(&str, &str); 23] = [ async fn insert_next_server_special_aliases( import_map: &mut ImportMap, + project_path: Vc, ty: ServerContextType, mode: NextMode, runtime: NextRuntime, @@ -553,6 +562,14 @@ async fn insert_next_server_special_aliases( (_, ServerContextType::Middleware) => {} } + import_map.insert_exact_alias( + "@vercel/og", + external_if_node( + project_path, + "next/dist/server/web/spec-extension/image-response", + ), + ); + Ok(()) } diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration.rs b/packages/next-swc/crates/next-dev-tests/tests/integration.rs index d67270678c61e..5c195dc22c016 100644 --- a/packages/next-swc/crates/next-dev-tests/tests/integration.rs +++ b/packages/next-swc/crates/next-dev-tests/tests/integration.rs @@ -552,6 +552,7 @@ async fn get_mock_server_future(mock_dir: &Path) -> Result<(), String> { Some(mock_dir.to_path_buf()), false, 0, + std::future::pending(), ) .await } else { diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx b/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx index 054f136e10f65..1b84fceedc0b9 100644 --- a/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx +++ b/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx @@ -17,27 +17,22 @@ export default function Test() { ).toEqual([ expect.objectContaining({ rel: 'manifest', - href: expect.stringMatching(/^\/_next\/static\/.+\.webmanifest$/), + href: expect.stringMatching(/^\/manifest\.webmanifest$/), sizes: null, }), expect.objectContaining({ rel: 'icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.ico$/), - sizes: '48x48', - }), - expect.objectContaining({ - rel: 'icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.png$/), + href: expect.stringMatching(/^\/icon\d+\.png\?.+$/), sizes: '32x32', }), expect.objectContaining({ rel: 'icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.png$/), + href: expect.stringMatching(/^\/icon\d+\.png\?.+$/), sizes: '64x64', }), expect.objectContaining({ rel: 'apple-touch-icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.png$/), + href: expect.stringMatching(/^\/apple-icon\.png\?.+$/), sizes: '114x114', }), ]) @@ -51,7 +46,7 @@ export default function Test() { .map((l) => [l.getAttribute('property'), l.getAttribute('content')]) ) expect(metaObject).toEqual({ - 'og:image': expect.stringMatching(/^.+\/_next\/static\/.+\.png$/), + 'og:image': expect.stringMatching(/^.+\/opengraph-image\.png\?.+$/), 'og:image:width': '114', 'og:image:height': '114', 'og:image:alt': 'This is an alt text.', diff --git a/packages/next/src/server/lib/router-utils/setup-dev.ts b/packages/next/src/server/lib/router-utils/setup-dev.ts index cf17fb2d7d9e6..5af0d92c3a2d8 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev.ts @@ -103,6 +103,7 @@ import { TurboPackConnectedAction, } from '../../dev/hot-reloader-types' import { debounce } from '../../utils' +import { normalizeMetadataRoute } from '../../../lib/metadata/get-metadata-route' const wsServer = new ws.Server({ noServer: true }) @@ -230,7 +231,7 @@ async function startWatcher(opts: SetupOpts) { } function formatIssue(issue: Issue) { - const { filePath, title, description, source } = issue + const { filePath, title, description, source, detail } = issue let formattedTitle = title.replace(/\n/g, '\n ') let message = '' @@ -265,6 +266,9 @@ async function startWatcher(opts: SetupOpts) { if (description) { message += `\n${description.replace(/\n/g, '\n ')}` } + if (detail) { + message += `\n${detail.replace(/\n/g, '\n ')}` + } return message } @@ -659,7 +663,7 @@ async function startWatcher(opts: SetupOpts) { async function writeBuildManifest(): Promise { const buildManifest = mergeBuildManifests(buildManifests.values()) - const buildManifestPath = path.join(distDir, 'build-manifest.json') + const buildManifestPath = path.join(distDir, BUILD_MANIFEST) await clearCache(buildManifestPath) await writeFile( buildManifestPath, @@ -896,6 +900,7 @@ async function startWatcher(opts: SetupOpts) { case 'client-success': // { clientId } case 'server-component-reload-page': // { clientId } case 'client-reload-page': // { clientId } + case 'client-removed-page': // { page } case 'client-full-reload': // { stackTrace, hadRuntimeError } // TODO break @@ -1012,7 +1017,13 @@ async function startWatcher(opts: SetupOpts) { } await currentEntriesHandling - const route = curEntries.get(page) + const route = + curEntries.get(page) ?? + curEntries.get( + normalizeAppPath( + normalizeMetadataRoute(match?.definition?.page ?? inputPage) + ) + ) if (!route) { // TODO: why is this entry missing in turbopack?