feat(turbopack): add dynamic metadata support (#54995)

Closes NEXT-1435
Closes WEB-1435
This commit is contained in:
Leah 2023-09-08 20:25:13 +02:00 committed by GitHub
parent f569cb1316
commit b5d752667a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1772 additions and 539 deletions

170
Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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<String, ReadRef<LoaderTreeForJs>>,
#[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<MetadataForJsItem>,
icon: Vec<MetadataWithAltItemForJs>,
#[serde(skip_serializing_if = "Vec::is_empty")]
apple: Vec<MetadataForJsItem>,
apple: Vec<MetadataWithAltItemForJs>,
#[serde(skip_serializing_if = "Vec::is_empty")]
twitter: Vec<MetadataForJsItem>,
twitter: Vec<MetadataWithAltItemForJs>,
#[serde(skip_serializing_if = "Vec::is_empty")]
open_graph: Vec<MetadataForJsItem>,
#[serde(skip_serializing_if = "Vec::is_empty")]
favicon: Vec<MetadataForJsItem>,
open_graph: Vec<MetadataWithAltItemForJs>,
#[serde(skip_serializing_if = "Option::is_none")]
sitemap: Option<MetadataItemForJs>,
}
#[derive(Default, Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)]
#[serde(rename_all = "camelCase")]
struct GlobalMetadataForJs {
#[serde(skip_serializing_if = "Option::is_none")]
favicon: Option<MetadataItemForJs>,
#[serde(skip_serializing_if = "Option::is_none")]
robots: Option<MetadataItemForJs>,
#[serde(skip_serializing_if = "Option::is_none")]
manifest: Option<MetadataItemForJs>,
}
#[derive(Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)]
#[serde(tag = "type", rename_all = "camelCase")]
enum MetadataForJsItem {
enum MetadataWithAltItemForJs {
Static {
path: String,
alt_path: Option<String>,
@ -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<FileSystemPath>,
components: Vc<Components>,
@ -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<MetadataForJsItem>,
project_path: Vc<FileSystemPath>,
value: impl Iterator<Item = &'a MetadataWithAltItem>,
) -> 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<MetadataWithAltItemForJs>,
project_path: Vc<FileSystemPath>,
value: impl Iterator<Item = &'a MetadataWithAltItem>,
) -> 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<MetadataItemForJs>,
project_path: Vc<FileSystemPath>,
value: Option<MetadataItem>,
) -> 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<FileSystemPath>,
loader_tree: Vc<LoaderTree>,
) -> Result<Vc<LoaderTreeForJs>> {
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))
}

View file

@ -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<NextMode> {
self.mode.cell()
}
#[turbo_tasks::function]
fn app_entrypoints(&self) -> Vc<AppEntrypoints> {
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<FileSystemPath>,
},
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<Vc<AppEntry>> {
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<Self>) -> Vc<OutputAssets> {
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();

View file

@ -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,

View file

@ -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 }

View file

@ -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<FileSystemPath>,
metadata: Vc<GlobalMetadata>,
server_root: Vc<FileSystemPath>,
) -> Result<Vc<Box<dyn ContentSource>>> {
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<FileSystemPath>,
context_ssr: Vc<ModuleAssetContext>,
project_path: Vc<FileSystemPath>,
@ -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<ModuleAssetContext>,
project_path: Vc<FileSystemPath>,
app_dir: Vc<FileSystemPath>,
env: Vc<Box<dyn ProcessEnv>>,
server_root: Vc<FileSystemPath>,
runtime_entries: Vc<Sources>,
intermediate_output_path_root: Vc<FileSystemPath>,
render_data: Vc<JsonValue>,
metadata: MetadataItem,
) -> Result<Vc<Box<dyn ContentSource>>> {
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<Sources>,
app_dir: Vc<FileSystemPath>,
context_ssr: Vc<ModuleAssetContext>,
context: Vc<ModuleAssetContext>,
project_path: Vc<FileSystemPath>,
@ -924,7 +934,6 @@ impl AppRenderer {
async fn entry(self: Vc<Self>, with_ssr: bool) -> Result<Vc<NodeRenderingEntry>> {
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<FileSystemPath>),
Metadata {
metadata: MetadataItem,
page: AppPage,
},
}
/// The node.js renderer api routes in the app directory
#[turbo_tasks::value]
struct AppRoute {
runtime_entries: Vc<Sources>,
context: Vc<ModuleAssetContext>,
entry_path: Vc<FileSystemPath>,
entry: AppRouteEntry,
mode: NextMode,
intermediate_output_path: Vc<FileSystemPath>,
project_path: Vc<FileSystemPath>,
server_root: Vc<FileSystemPath>,
@ -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)),
);

View file

@ -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<FileSystemPath> },
}
#[turbo_tasks::function]
pub async fn get_metadata_route_name(meta: MetadataItem) -> Result<Vc<String>> {
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<FileSystemPath> {
match self {
MetadataItem::Static { path } => path,
MetadataItem::Dynamic { path } => path,
}
}
}
impl From<MetadataWithAltItem> 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<MetadataWithAltItem>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub open_graph: Vec<MetadataWithAltItem>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub favicon: Vec<MetadataWithAltItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<MetadataItem>,
pub sitemap: Option<MetadataItem>,
}
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<MetadataItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sitemap: Option<MetadataItem>,
pub manifest: Option<MetadataItem>,
}
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<FileSystemPath>) -> Result
Ok(find_app_dir(project_path))
}
static STATIC_LOCAL_METADATA: Lazy<HashMap<&'static str, &'static [&'static str]>> =
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<HashMap<&'static str, &'static [&'static str]>> =
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<Regex> = 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<FileSystemPath>,
@ -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 });
let Some(MetadataFileMatch {
metadata_type,
number,
dynamic,
}) = match_local_metadata_file(basename.as_str(), &page_extensions_value)
else {
continue;
};
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 {
components.metadata.sitemap =
Some(MetadataItem::Dynamic { path: file });
} else {
components.metadata.sitemap = Some(MetadataItem::Static { path: file });
}
continue;
}
_ => 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,
};
if let Some(entry) = entry {
if dynamic {
entry.push((num, MetadataWithAltItem::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,
},
));
}
}
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<T>(mut list: Vec<(i32, T)>) -> Vec<T> {
fn sort<T>(mut list: Vec<(Option<u32>, T)>) -> Vec<T> {
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<String, Vc<LoaderTree>>,
pub components: Vc<Components>,
pub global_metadata: Vc<GlobalMetadata>,
}
#[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<FileSystemPath>,
},
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<FileSystemPath>,
result: &mut IndexMap<String, Entrypoint>,
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<FileSystemPath>,
page_extensions: Vc<Vec<String>>,
) -> Vc<Entrypoints> {
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<FileSystemPath>,
directory_tree: Vc<DirectoryTree>,
global_metadata: Vc<GlobalMetadata>,
) -> Vc<Entrypoints> {
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<FileSystemPath>,
global_metadata: Vc<GlobalMetadata>,
directory_name: String,
directory_tree: Vc<DirectoryTree>,
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
}

View file

@ -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<String>,
loader_tree_code: String,
context: Vc<ModuleAssetContext>,
unsupported_metadata: Vec<Vc<FileSystemPath>>,
mode: NextMode,
server_component_transition: ServerComponentTransition,
pages: Vec<Vc<FileSystemPath>>,
@ -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<Vc<FileSystemPath>>,
) -> 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,118 +179,176 @@ 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<MetadataItem>) -> Result<()> {
async fn write_metadata_manifest(&mut self, manifest: Option<MetadataItem>) -> 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<Item = &'a MetadataWithAltItem>,
) -> 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;
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,
self.context.process(
source,
Value::new(ReferenceType::EcmaScriptModules(
EcmaScriptModulesReferenceSubType::Undefined,
)),
),
);
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<FileSystemPath>,
alt_path: Option<Vc<FileSystemPath>>,
) -> 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 = " ";
match item {
MetadataWithAltItem::Static { path, alt_path } => {
self.inner_assets.insert(
inner_module_id,
Vc::upcast(StructuredImageModuleType::create_module(
Vc::upcast(FileSource::new(*path)),
BlurPlaceholderMode::None,
self.context,
)),
);
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);
}
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(())
}
@ -293,9 +357,11 @@ impl LoaderTreeBuilder {
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<String>,
pub loader_tree_code: String,
pub inner_assets: IndexMap<String, Vc<Box<dyn Module>>>,
pub unsupported_metadata: Vec<Vc<FileSystemPath>>,
pub pages: Vec<Vc<FileSystemPath>>,
}

View file

@ -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,

View file

@ -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<ModuleAssetContext>,
edge_context: Vc<ModuleAssetContext>,
loader_tree: Vc<LoaderTree>,
app_dir: Vc<FileSystemPath>,
page: AppPage,
project_root: Vc<FileSystemPath>,
) -> Result<Vc<AppEntry>> {
@ -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),
}
}

View file

@ -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),
}
}

View file

@ -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<FileSystemPath>) -> Result<u64> {
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<Box<dyn AssetContext>>,
path: Vc<FileSystemPath>,
ty: String,
page: AppPage,
) -> Result<Vc<Box<dyn Source>>> {
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::<Vec<_>>()
.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<Box<dyn Module>>) -> Result<Vc<Vec<String>>> {
let Some(ecmascript_asset) =
Vc::try_resolve_downcast_type::<EcmascriptModuleAsset>(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()))
}

View file

@ -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<HashMap<&'static str, &'static [&'static str]>> =
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<HashMap<&'static str, &'static [&'static str]>> =
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<u32>,
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<MetadataFileMatch<'a>> {
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<MetadataFileMatch<'a>> {
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<GlobalMetadataFileMatch<'a>> {
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<String> {
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<AppPage> {
if !is_metadata_route(&format!("{page}")) {
return Ok(page);
}
let mut route = page.to_string();
let mut suffix: Option<String> = 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 /<metadata-route.ext> and custom routes
// /<metadata-route>/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);
}
}
}

View file

@ -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<Vc<Box<dyn Source>>> {
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<ModuleAssetContext>,
edge_context: Vc<ModuleAssetContext>,
project_root: Vc<FileSystemPath>,
page: AppPage,
mode: NextMode,
metadata: MetadataItem,
) -> Vc<AppEntry> {
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<FileSystemPath>) -> Result<String> {
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<FileSystemPath>) -> Result<String> {
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<FileSystemPath>,
) -> Result<Vc<Box<dyn Source>>> {
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<FileSystemPath>) -> Result<Vc<Box<dyn Source>>> {
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<FileSystemPath>,
page: AppPage,
) -> Result<Vc<Box<dyn Source>>> {
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<FileSystemPath>) -> Result<Vc<Box<dyn Source>>> {
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))
}

View file

@ -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> {
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,
)]

View file

@ -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<FileSystemPath>,
pub files: Vec<Vc<FileSystemPath>>,
}
#[turbo_tasks::value_impl]
impl Issue for UnsupportedDynamicMetadataIssue {
#[turbo_tasks::function]
fn severity(&self) -> Vc<IssueSeverity> {
IssueSeverity::Warning.into()
}
#[turbo_tasks::function]
fn category(&self) -> Vc<String> {
Vc::cell("unsupported".to_string())
}
#[turbo_tasks::function]
fn file_path(&self) -> Vc<FileSystemPath> {
self.app_dir
}
#[turbo_tasks::function]
fn title(&self) -> Vc<String> {
Vc::cell(
"Dynamic metadata from filesystem is currently not supported in Turbopack".to_string(),
)
}
#[turbo_tasks::function]
async fn description(&self) -> Result<Vc<String>> {
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]))
)))
}
}

View file

@ -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> = 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<FileSystemPath>,
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(())
}

View file

@ -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 {

View file

@ -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.',

View file

@ -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<void> {
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?