diff --git a/docs/02-app/02-api-reference/02-file-conventions/01-metadata/sitemap.mdx b/docs/02-app/02-api-reference/02-file-conventions/01-metadata/sitemap.mdx index 75fa2686bda13..4aa94f9c022fb 100644 --- a/docs/02-app/02-api-reference/02-file-conventions/01-metadata/sitemap.mdx +++ b/docs/02-app/02-api-reference/02-file-conventions/01-metadata/sitemap.mdx @@ -97,7 +97,7 @@ export default function sitemap() { Output: -```xml filename="acme.com/sitemap" +```xml filename="acme.com/sitemap.xml" https://acme.com @@ -163,7 +163,7 @@ export default function sitemap(): MetadataRoute.Sitemap { Output: -```xml filename="acme.com/sitemap" +```xml filename="acme.com/sitemap.xml" https://acme.com @@ -264,7 +264,7 @@ export default async function sitemap({ id }) { } ``` -Your generated sitemaps will be available at `/.../sitemap/[id]`. For example, `/product/sitemap/1`. +Your generated sitemaps will be available at `/.../sitemap/[id]`. For example, `/product/sitemap/1.xml`. See the [`generateSitemaps` API reference](/docs/app/api-reference/functions/generate-sitemaps) for more information. diff --git a/packages/next-swc/crates/next-core/src/app_segment_config.rs b/packages/next-swc/crates/next-core/src/app_segment_config.rs index a4a76ede1533b..9b815d44f8d2c 100644 --- a/packages/next-swc/crates/next-core/src/app_segment_config.rs +++ b/packages/next-swc/crates/next-core/src/app_segment_config.rs @@ -8,7 +8,7 @@ use turbo_tasks_fs::FileSystemPath; use turbopack_binding::{ swc::core::{ common::{source_map::Pos, Span, Spanned, GLOBALS}, - ecma::ast::{Expr, Ident, Program}, + ecma::ast::{Decl, Expr, FnExpr, Ident, Program}, }, turbopack::{ core::{ @@ -73,6 +73,9 @@ pub struct NextSegmentConfig { pub runtime: Option, pub preferred_region: Option>, pub experimental_ppr: Option, + /// Wether these metadata exports are defined in the source file. + pub generate_image_metadata: bool, + pub generate_sitemaps: bool, } #[turbo_tasks::value_impl] @@ -95,6 +98,7 @@ impl NextSegmentConfig { runtime, preferred_region, experimental_ppr, + .. } = self; *dynamic = dynamic.or(parent.dynamic); *dynamic_params = dynamic_params.or(parent.dynamic_params); @@ -137,6 +141,7 @@ impl NextSegmentConfig { runtime, preferred_region, experimental_ppr, + .. } = self; merge_parallel(dynamic, ¶llel_config.dynamic, "dynamic")?; merge_parallel( @@ -272,22 +277,35 @@ pub async fn parse_segment_config_from_source( let mut config = NextSegmentConfig::default(); for item in &module_ast.body { - let Some(decl) = item + let Some(export_decl) = item .as_module_decl() .and_then(|mod_decl| mod_decl.as_export_decl()) - .and_then(|export_decl| export_decl.decl.as_var()) else { continue; }; - for decl in &decl.decls { - let Some(ident) = decl.name.as_ident().map(|ident| ident.deref()) else { - continue; - }; + match &export_decl.decl { + Decl::Var(var_decl) => { + for decl in &var_decl.decls { + let Some(ident) = decl.name.as_ident().map(|ident| ident.deref()) else { + continue; + }; - if let Some(init) = decl.init.as_ref() { - parse_config_value(source, &mut config, ident, init, eval_context); + if let Some(init) = decl.init.as_ref() { + parse_config_value(source, &mut config, ident, init, eval_context); + } + } + } + Decl::Fn(fn_decl) => { + let ident = &fn_decl.ident; + // create an empty expression of {}, we don't need init for function + let init = Expr::Fn(FnExpr { + ident: None, + function: fn_decl.function.clone(), + }); + parse_config_value(source, &mut config, ident, &init, eval_context); } + _ => {} } } config @@ -431,6 +449,14 @@ fn parse_config_value( config.preferred_region = Some(preferred_region); } + // Match exported generateImageMetadata function and generateSitemaps function, and pass + // them to config. + "generateImageMetadata" => { + config.generate_image_metadata = true; + } + "generateSitemaps" => { + config.generate_sitemaps = true; + } "experimental_ppr" => { let value = eval_context.eval(init); let Some(val) = value.as_bool() else { 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 index ac35312e3a235..08af0f5908a72 100644 --- 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 @@ -306,8 +306,7 @@ pub fn normalize_metadata_route(mut page: AppPage) -> Result { route += ".txt" } else if route == "/manifest" { route += ".webmanifest" - // Do not append the suffix for the sitemap route - } else if !route.ends_with("/sitemap") { + } 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); @@ -317,13 +316,8 @@ pub fn normalize_metadata_route(mut page: AppPage) -> Result { // //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(&page.to_string()); 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( @@ -338,10 +332,6 @@ pub fn normalize_metadata_route(mut page: AppPage) -> Result { .into(), ))?; - if !is_static_route { - page.push(PageSegment::OptionalCatchAll("__metadata_id__".into()))?; - } - page.push(PageSegment::PageType(PageType::Route))?; } @@ -358,11 +348,11 @@ mod test { let cases = vec![ [ "/client/(meme)/more-route/twitter-image", - "/client/(meme)/more-route/twitter-image-769mad/[[...__metadata_id__]]/route", + "/client/(meme)/more-route/twitter-image-769mad/route", ], [ "/client/(meme)/more-route/twitter-image2", - "/client/(meme)/more-route/twitter-image2-769mad/[[...__metadata_id__]]/route", + "/client/(meme)/more-route/twitter-image2-769mad/route", ], ["/robots.txt", "/robots.txt/route"], ["/manifest.webmanifest", "/manifest.webmanifest/route"], 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 index 7227f34bdce9d..d978de8369d3f 100644 --- 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 @@ -2,7 +2,7 @@ //! //! See `next/src/build/webpack/loaders/next-metadata-route-loader` -use anyhow::{bail, Result}; +use anyhow::{bail, Ok, Result}; use base64::{display::Base64Display, engine::general_purpose::STANDARD}; use indoc::{formatdoc, indoc}; use turbo_tasks::{ValueToString, Vc}; @@ -22,7 +22,9 @@ use super::get_content_type; use crate::{ app_structure::MetadataItem, mode::NextMode, - next_app::{app_entry::AppEntry, app_route_entry::get_app_route_entry, AppPage, PageSegment}, + next_app::{ + app_entry::AppEntry, app_route_entry::get_app_route_entry, AppPage, PageSegment, PageType, + }, next_config::NextConfig, parse_segment_config_from_source, }; @@ -30,9 +32,9 @@ use crate::{ /// 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, + is_multi_dynamic: bool, ) -> Result>> { Ok(match metadata { MetadataItem::Static { path } => static_route_source(mode, path), @@ -43,7 +45,7 @@ pub async fn get_app_metadata_route_source( if stem == "robots" || stem == "manifest" { dynamic_text_route_source(path) } else if stem == "sitemap" { - dynamic_site_map_route_source(mode, path, page) + dynamic_site_map_route_source(mode, path, is_multi_dynamic) } else { dynamic_image_route_source(path) } @@ -52,11 +54,11 @@ pub async fn get_app_metadata_route_source( } #[turbo_tasks::function] -pub fn get_app_metadata_route_entry( +pub async fn get_app_metadata_route_entry( nodejs_context: Vc, edge_context: Vc, project_root: Vc, - page: AppPage, + mut page: AppPage, mode: NextMode, metadata: MetadataItem, next_config: Vc, @@ -69,11 +71,43 @@ pub fn get_app_metadata_route_entry( let source = Vc::upcast(FileSource::new(original_path)); let segment_config = parse_segment_config_from_source(source); + let is_dynamic_metadata = matches!(metadata, MetadataItem::Dynamic { .. }); + let is_multi_dynamic: bool = if Some(segment_config).is_some() { + // is_multi_dynamic is true when config.generateSitemaps or + // config.generateImageMetadata is defined in dynamic routes + let config = segment_config.await.unwrap(); + config.generate_sitemaps || config.generate_image_metadata + } else { + false + }; + + // Map dynamic sitemap and image routes based on the exports. + // if there's generator export: add /[__metadata_id__] to the route; + // otherwise keep the original route. + // For sitemap, if the last segment is sitemap, appending .xml suffix. + if is_dynamic_metadata { + // remove the last /route segment of page + page.0.pop(); + + let _ = if is_multi_dynamic { + page.push(PageSegment::Dynamic("__metadata_id__".into())) + } else { + // if page last segment is sitemap, change to sitemap.xml + if page.last() == Some(&PageSegment::Static("sitemap".into())) { + page.0.pop(); + page.push(PageSegment::Static("sitemap.xml".into())) + } else { + Ok(()) + } + }; + // Push /route back + let _ = page.push(PageSegment::PageType(PageType::Route)); + }; get_app_route_entry( nodejs_context, edge_context, - get_app_metadata_route_source(page.clone(), mode, metadata), + get_app_metadata_route_source(mode, metadata, is_multi_dynamic), page, project_root, Some(segment_config), @@ -208,26 +242,24 @@ async fn dynamic_text_route_source(path: Vc) -> Result, - page: AppPage, + is_multi_dynamic: bool, ) -> 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.is_production() && page.contains(&PageSegment::Dynamic("[__metadata_id__]".into())) { + if mode.is_production() && is_multi_dynamic { 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() }) - } + for (const item of sitemaps) {{ + params.push({ __metadata_id__: item.id.toString() + '.xml' }) + }} return params } "#, @@ -252,29 +284,25 @@ async fn dynamic_site_map_route_source( }} 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 + const {{ __metadata_id__: id, ...params }} = ctx.params || {{}} + const hasXmlExtension = id ? id.endsWith('.xml') : false + if (id && !hasXmlExtension) {{ + return new NextResponse('Not Found', {{ + status: 404, + }}) + }} - 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') - }} + if (process.env.NODE_ENV !== 'production' && sitemapModule.generateSitemaps) {{ + const sitemaps = await sitemapModule.generateSitemaps() + for (const item of sitemaps) {{ + 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 targetId = id && hasXmlExtension ? id.slice(0, -4) : undefined + const data = await handler({{ id: targetId }}) const content = resolveRouteData(data, fileType) return new NextResponse(content, {{ @@ -324,12 +352,12 @@ async fn dynamic_image_route_source(path: Vc) -> Result {{ if (process.env.NODE_ENV !== 'production') {{ if (item?.id == null) {{ diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index 97dd5b2f69455..d77c236e10d53 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -59,6 +59,8 @@ export interface PageStaticInfo { ssr?: boolean rsc?: RSCModuleType generateStaticParams?: boolean + generateSitemaps?: boolean + generateImageMetadata?: boolean middleware?: MiddlewareConfigParsed amp?: boolean | 'hybrid' extraConfig?: Record @@ -141,8 +143,8 @@ function checkExports( ssg: boolean runtime?: string preferredRegion?: string | string[] - generateImageMetadata?: boolean - generateSitemaps?: boolean + generateImageMetadata: boolean + generateSitemaps: boolean generateStaticParams: boolean extraProperties?: Set directives?: Set @@ -467,20 +469,6 @@ function warnAboutUnsupportedValue( warnedUnsupportedValueMap.set(pageFilePath, true) } -// Detect if metadata routes is a dynamic route, which containing -// generateImageMetadata or generateSitemaps as export -export async function isDynamicMetadataRoute( - pageFilePath: string -): Promise { - const fileContent = (await tryToReadFile(pageFilePath, true)) || '' - if (/generateImageMetadata|generateSitemaps/.test(fileContent)) { - const swcAST = await parseModule(pageFilePath, fileContent) - const exportsInfo = checkExports(swcAST, pageFilePath) - return !!(exportsInfo.generateImageMetadata || exportsInfo.generateSitemaps) - } - return false -} - /** * For a given pageFilePath and nextConfig, if the config supports it, this * function will read the file and return the runtime that should be used. @@ -499,7 +487,7 @@ export async function getPageStaticInfo(params: { const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' if ( - /(? { const isAppRoute = pagesType === 'app' - const pages = pagePaths.reduce<{ [key: string]: string }>( - (result, pagePath) => { - // Do not process .d.ts files as routes - if (pagePath.endsWith('.d.ts') && pageExtensions.includes('ts')) { - return result - } + const pages: MappedPages = {} + const promises = pagePaths.map>(async (pagePath) => { + // Do not process .d.ts files as routes + if (pagePath.endsWith('.d.ts') && pageExtensions.includes('ts')) { + return + } - let pageKey = getPageFromPath(pagePath, pageExtensions) - if (isAppRoute) { - pageKey = pageKey.replace(/%5F/g, '_') - if (pageKey === '/not-found') { - pageKey = UNDERSCORE_NOT_FOUND_ROUTE_ENTRY - } + let pageKey = getPageFromPath(pagePath, pageExtensions) + if (isAppRoute) { + pageKey = pageKey.replace(/%5F/g, '_') + if (pageKey === '/not-found') { + pageKey = UNDERSCORE_NOT_FOUND_ROUTE_ENTRY } + } - const normalizedPath = normalizePathSep( - join( - pagesType === 'pages' - ? PAGES_DIR_ALIAS - : pagesType === 'app' - ? APP_DIR_ALIAS - : ROOT_DIR_ALIAS, - pagePath - ) + const normalizedPath = normalizePathSep( + join( + pagesType === 'pages' + ? PAGES_DIR_ALIAS + : pagesType === 'app' + ? APP_DIR_ALIAS + : ROOT_DIR_ALIAS, + pagePath ) + ) - const route = - pagesType === 'app' ? normalizeMetadataRoute(pageKey) : pageKey - result[route] = normalizedPath - return result - }, - {} - ) + let route = pagesType === 'app' ? normalizeMetadataRoute(pageKey) : pageKey + + if (isMetadataRoute(route) && pagesType === 'app') { + const filePath = join(appDir!, pagePath) + const staticInfo = await getPageStaticInfo({ + nextConfig: {}, + pageFilePath: filePath, + isDev, + page: pageKey, + pageType: pagesType, + }) + + route = normalizeMetadataPageToRoute( + route, + !!(staticInfo.generateImageMetadata || staticInfo.generateSitemaps) + ) + } + + pages[route] = normalizedPath + }) + + await Promise.all(promises) switch (pagesType) { case PAGE_TYPES.ROOT: { diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 5e2d15e62d2aa..6729c182a510e 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -100,11 +100,8 @@ import { } from '../telemetry/events' import type { EventBuildFeatureUsage } from '../telemetry/events' import { Telemetry } from '../telemetry/storage' -import { - isDynamicMetadataRoute, - getPageStaticInfo, -} from './analysis/get-page-static-info' -import { createPagesMapping, getPageFilePath, sortByPageExts } from './entries' +import { getPageStaticInfo } from './analysis/get-page-static-info' +import { createPagesMapping, sortByPageExts } from './entries' import { PAGE_TYPES } from '../lib/page-types' import { generateBuildId } from './generate-build-id' import { isWriteable } from './is-writeable' @@ -912,15 +909,16 @@ export default async function build( } NextBuildContext.previewProps = previewProps - const mappedPages = nextBuildSpan + const mappedPages = await nextBuildSpan .traceChild('create-pages-mapping') - .traceFn(() => + .traceAsyncFn(() => createPagesMapping({ isDev: false, pageExtensions: config.pageExtensions, pagesType: PAGE_TYPES.PAGES, pagePaths: pagesPaths, pagesDir, + appDir, }) ) NextBuildContext.mappedPages = mappedPages @@ -942,60 +940,29 @@ export default async function build( }) ) - mappedAppPages = nextBuildSpan + mappedAppPages = await nextBuildSpan .traceChild('create-app-mapping') - .traceFn(() => + .traceAsyncFn(() => createPagesMapping({ pagePaths: appPaths, isDev: false, pagesType: PAGE_TYPES.APP, pageExtensions: config.pageExtensions, - pagesDir: pagesDir, - }) - ) - - // If the metadata route doesn't contain generating dynamic exports, - // we can replace the dynamic catch-all route and use the static route instead. - for (const [pageKey, pagePath] of Object.entries(mappedAppPages)) { - if (pageKey.includes('[[...__metadata_id__]]')) { - const pageFilePath = getPageFilePath({ - absolutePagePath: pagePath, pagesDir, appDir, - rootDir, }) - - const isDynamic = await isDynamicMetadataRoute(pageFilePath) - if (!isDynamic) { - delete mappedAppPages[pageKey] - mappedAppPages[pageKey.replace('[[...__metadata_id__]]/', '')] = - pagePath - } - - if ( - pageKey.includes('sitemap/[[...__metadata_id__]]') && - isDynamic - ) { - delete mappedAppPages[pageKey] - mappedAppPages[ - pageKey.replace( - 'sitemap/[[...__metadata_id__]]', - 'sitemap/[__metadata_id__]' - ) - ] = pagePath - } - } - } + ) NextBuildContext.mappedAppPages = mappedAppPages } - const mappedRootPaths = createPagesMapping({ + const mappedRootPaths = await createPagesMapping({ isDev: false, pageExtensions: config.pageExtensions, pagePaths: rootPaths, pagesType: PAGE_TYPES.ROOT, pagesDir: pagesDir, + appDir, }) NextBuildContext.mappedRootPaths = mappedRootPaths @@ -1223,11 +1190,6 @@ export default async function build( '{"type": "commonjs"}' ) - // We need to write the manifest with rewrites before build - await nextBuildSpan - .traceChild('write-routes-manifest') - .traceAsyncFn(() => writeManifest(routesManifestPath, routesManifest)) - await writeEdgePartialPrerenderManifest(distDir, {}) const outputFileTracingRoot = @@ -2338,9 +2300,14 @@ export default async function build( return buildDataRoute(page, buildId) }) - await writeManifest(routesManifestPath, routesManifest) + // await writeManifest(routesManifestPath, routesManifest) } + // We need to write the manifest with rewrites before build + await nextBuildSpan + .traceChild('write-routes-manifest') + .traceAsyncFn(() => writeManifest(routesManifestPath, routesManifest)) + // Since custom _app.js can wrap the 404 page we have to opt-out of static optimization if it has getInitialProps // Only export the static 404 when there is no /_error present const useStaticPages404 = diff --git a/packages/next/src/build/output/store.ts b/packages/next/src/build/output/store.ts index b77ebd8f8545c..f761ba6055075 100644 --- a/packages/next/src/build/output/store.ts +++ b/packages/next/src/build/output/store.ts @@ -24,13 +24,13 @@ export type OutputState = } )) -const internalSegments = ['[[...__metadata_id__]]', '[__metadata_id__]'] export function formatTrigger(trigger: string) { - for (const segment of internalSegments) { - if (trigger.includes(segment)) { - trigger = trigger.replace(segment, '') - } + // Format dynamic sitemap routes to simpler file path + // e.g., /sitemap.xml[] -> /sitemap.xml + if (trigger.includes('[__metadata_id__]')) { + trigger = trigger.replace('/[__metadata_id__]', '/[id]') } + if (trigger.length > 1 && trigger.endsWith('/')) { trigger = trigger.slice(0, -1) } diff --git a/packages/next/src/build/webpack/loaders/metadata/discover.ts b/packages/next/src/build/webpack/loaders/metadata/discover.ts index c0264c4e50d61..74d0403ed6d2d 100644 --- a/packages/next/src/build/webpack/loaders/metadata/discover.ts +++ b/packages/next/src/build/webpack/loaders/metadata/discover.ts @@ -10,6 +10,7 @@ import type { MetadataResolver } from '../next-app-loader' import type { PageExtensions } from '../../../page-extensions-type' const METADATA_TYPE = 'metadata' +const NUMERIC_SUFFIX_ARRAY = Array(10).fill(0) // Produce all compositions with filename (icon, apple-icon, etc.) with extensions (png, jpg, etc.) async function enumMetadataFiles( @@ -27,11 +28,10 @@ async function enumMetadataFiles( ): Promise { const collectedFiles: string[] = [] + // Collect ., []. const possibleFileNames = [filename].concat( numericSuffix - ? Array(10) - .fill(0) - .map((_, index) => filename + index) + ? NUMERIC_SUFFIX_ARRAY.map((_, index) => filename + index) : [] ) for (const name of possibleFileNames) { @@ -91,14 +91,15 @@ export async function createStaticMetadataFromRoute( return } + const isFavicon = type === 'favicon' const resolvedMetadataFiles = await enumMetadataFiles( resolvedDir, STATIC_METADATA_IMAGES[type].filename, [ ...STATIC_METADATA_IMAGES[type].extensions, - ...(type === 'favicon' ? [] : pageExtensions), + ...(isFavicon ? [] : pageExtensions), ], - { metadataResolver, numericSuffix: true } + { metadataResolver, numericSuffix: !isFavicon } ) resolvedMetadataFiles .sort((a, b) => a.localeCompare(b)) diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts index 6c781d76e7feb..728cc5f8427ee 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -121,15 +121,15 @@ async function createAppRouteCode({ // If this is a metadata route, then we need to use the metadata loader for // the route to ensure that the route is generated. - const filename = path.parse(resolvedPagePath).name - if (isMetadataRoute(name) && filename !== 'route') { + const fileBaseName = path.parse(resolvedPagePath).name + if (isMetadataRoute(name) && fileBaseName !== 'route') { const { ext } = getFilenameAndExtension(resolvedPagePath) - const isDynamic = pageExtensions.includes(ext) + const isDynamicRouteExtension = pageExtensions.includes(ext) resolvedPagePath = `next-metadata-route-loader?${stringify({ page, filePath: resolvedPagePath, - isDynamic: isDynamic ? '1' : '0', + isDynamicRouteExtension: isDynamicRouteExtension ? '1' : '0', })}!?${WEBPACK_RESOURCE_QUERIES.metadataRoute}` } @@ -142,7 +142,7 @@ async function createAppRouteCode({ VAR_USERLAND: resolvedPagePath, VAR_DEFINITION_PAGE: page, VAR_DEFINITION_PATHNAME: pathname, - VAR_DEFINITION_FILENAME: filename, + VAR_DEFINITION_FILENAME: fileBaseName, VAR_DEFINITION_BUNDLE_PATH: bundlePath, VAR_RESOLVED_PAGE_PATH: resolvedPagePath, VAR_ORIGINAL_PATHNAME: page, diff --git a/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts b/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts index 5adb640f7ce33..b0cee835f9d40 100644 --- a/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts @@ -23,13 +23,17 @@ const cacheHeader = { type MetadataRouteLoaderOptions = { page: string filePath: string - isDynamic: '1' | '0' + isDynamicRouteExtension: '1' | '0' + isDynamicMultiRoute: '1' | '0' } export function getFilenameAndExtension(resourcePath: string) { const filename = path.basename(resourcePath) const [name, ext] = filename.split('.', 2) - return { name, ext } + return { + name, + ext, + } } function getContentType(resourcePath: string) { @@ -123,11 +127,11 @@ ${errorOnBadHandler(resourcePath)} export async function GET(_, ctx) { const { __metadata_id__, ...params } = ctx.params || {} - const targetId = __metadata_id__?.[0] + const targetId = __metadata_id__ let id = undefined - const imageMetadata = generateImageMetadata ? await generateImageMetadata({ params }) : null - - if (imageMetadata) { + + if (generateImageMetadata) { + const imageMetadata = await generateImageMetadata({ params }) id = imageMetadata.find((item) => { if (process.env.NODE_ENV !== 'production') { if (item?.id == null) { @@ -142,14 +146,14 @@ export async function GET(_, ctx) { }) } } + return handler({ params: ctx.params ? params : undefined, id }) } ` } -async function getDynamicSiteMapRouteCode( +async function getDynamicSitemapRouteCode( resourcePath: string, - page: string, loaderContext: webpack.LoaderContext ) { let staticGenerationCode = '' @@ -163,23 +167,20 @@ async function getDynamicSiteMapRouteCode( (name) => name !== 'default' && name !== 'generateSitemaps' ) - const hasGenerateSiteMaps = exportNames.includes('generateSitemaps') - if ( - process.env.NODE_ENV === 'production' && - hasGenerateSiteMaps && - page.includes('[__metadata_id__]') - ) { + const hasGenerateSitemaps = exportNames.includes('generateSitemaps') + + if (process.env.NODE_ENV === 'production' && hasGenerateSitemaps) { staticGenerationCode = `\ -/* dynamic sitemap route */ -export async function generateStaticParams() { - const sitemaps = generateSitemaps ? await generateSitemaps() : [] - const params = [] + /* dynamic sitemap route */ + export async function generateStaticParams() { + const sitemaps = await sitemapModule.generateSitemaps() + const params = [] - for (const item of sitemaps) { - params.push({ __metadata_id__: item.id.toString() }) - } - return params -} + for (const item of sitemaps) { + params.push({ __metadata_id__: item.id.toString() + '.xml' }) + } + return params + } ` } @@ -190,7 +191,6 @@ import { resolveRouteData } from 'next/dist/build/webpack/loaders/metadata/resol const sitemapModule = { ...userland } const handler = sitemapModule.default -const generateSitemaps = sitemapModule.generateSitemaps const contentType = ${JSON.stringify(getContentType(resourcePath))} const fileType = ${JSON.stringify(getFilenameAndExtension(resourcePath).name)} @@ -206,35 +206,27 @@ ${ } export async function GET(_, ctx) { - const { __metadata_id__, ...params } = ctx.params || {} - ${ - '' /* sitemap will be optimized to [__metadata_id__] from [[..._metadata_id__]] in production */ - } - const targetId = process.env.NODE_ENV !== 'production' - ? __metadata_id__?.[0] - : __metadata_id__ + const { __metadata_id__: id, ...params } = ctx.params || {} + const hasXmlExtension = id ? id.endsWith('.xml') : false - let id = undefined - const sitemaps = generateSitemaps ? await generateSitemaps() : null + if (id && !hasXmlExtension) { + return new NextResponse('Not Found', { + status: 404, + }) + } - 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') - } + if (process.env.NODE_ENV !== 'production' && sitemapModule.generateSitemaps) { + const sitemaps = await sitemapModule.generateSitemaps() + for (const item of sitemaps) { + if (item?.id == null) { + throw new Error('id property is required for every item returned from generateSitemaps') } - let itemID = item.id.toString() - return itemID === targetId - })?.id - if (id == null) { - return new NextResponse('Not Found', { - status: 404, - }) } } - const data = await handler({ id }) + const targetId = id && hasXmlExtension ? id.slice(0, -4) : undefined + + const data = await handler({ id: targetId }) const content = resolveRouteData(data, fileType) return new NextResponse(content, { @@ -254,15 +246,15 @@ ${staticGenerationCode} // TODO-METADATA: improve the cache control strategy const nextMetadataRouterLoader: webpack.LoaderDefinitionFunction = async function () { - const { page, isDynamic, filePath } = this.getOptions() + const { isDynamicRouteExtension, filePath } = this.getOptions() const { name: fileBaseName } = getFilenameAndExtension(filePath) let code = '' - if (isDynamic === '1') { + if (isDynamicRouteExtension === '1') { if (fileBaseName === 'robots' || fileBaseName === 'manifest') { code = getDynamicTextRouteCode(filePath) } else if (fileBaseName === 'sitemap') { - code = await getDynamicSiteMapRouteCode(filePath, page, this) + code = await getDynamicSitemapRouteCode(filePath, this) } else { code = getDynamicImageRouteCode(filePath) } diff --git a/packages/next/src/export/routes/app-route.ts b/packages/next/src/export/routes/app-route.ts index 7c5218bb9df13..66db13b36347a 100644 --- a/packages/next/src/export/routes/app-route.ts +++ b/packages/next/src/export/routes/app-route.ts @@ -25,10 +25,7 @@ import { SERVER_DIRECTORY } from '../../shared/lib/constants' import { hasNextSupport } from '../../telemetry/ci-info' import { isStaticGenEnabled } from '../../server/route-modules/app-route/helpers/is-static-gen-enabled' import type { ExperimentalConfig } from '../../server/config-shared' -import { - isMetadataRouteFile, - isStaticMetadataRoute, -} from '../../lib/metadata/is-metadata-route' +import { isMetadataRouteFile } from '../../lib/metadata/is-metadata-route' import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' export const enum ExportedAppRouteFiles { @@ -97,9 +94,7 @@ export async function exportAppRoute( // we don't bail from the static optimization for // metadata routes const normalizedPage = normalizeAppPath(page) - const isMetadataRoute = - isStaticMetadataRoute(normalizedPage) || - isMetadataRouteFile(`${normalizedPage}.ts`, ['ts'], true) + const isMetadataRoute = isMetadataRouteFile(normalizedPage, [], false) if (!isStaticGenEnabled(userland) && !isMetadataRoute) { return { revalidate: 0 } diff --git a/packages/next/src/lib/metadata/get-metadata-route.ts b/packages/next/src/lib/metadata/get-metadata-route.ts index 8bccf25b42647..dff0ae500c8ac 100644 --- a/packages/next/src/lib/metadata/get-metadata-route.ts +++ b/packages/next/src/lib/metadata/get-metadata-route.ts @@ -1,4 +1,4 @@ -import { isMetadataRoute, isStaticMetadataRoute } from './is-metadata-route' +import { isMetadataRoute } from './is-metadata-route' import path from '../../shared/lib/isomorphic/path' import { interpolateDynamicPath } from '../../server/server-utils' import { getNamedRouteRegex } from '../../shared/lib/router/utils/route-regex' @@ -11,8 +11,8 @@ import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' * 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} + * /app/opengraph-image.tsx -> /opengraph-image + * /app/(post)/opengraph-image.tsx -> /opengraph-image-[0-9a-z]{6} */ function getMetadataRouteSuffix(page: string) { let suffix = '' @@ -65,10 +65,10 @@ export function normalizeMetadataRoute(page: string) { route += '.txt' } else if (page === '/manifest') { route += '.webmanifest' - } - // For sitemap, we don't automatically add the route suffix since it can have sub-routes - else if (!page.endsWith('/sitemap')) { - // Remove the file extension, e.g. /route-path/robots.txt -> /route-path + } else { + // Remove the file extension, + // e.g. /path/robots.txt -> /route-path + // e.g. /path/opengraph-image.tsx -> /path/opengraph-image const pathnamePrefix = page.slice(0, -(path.basename(page).length + 1)) suffix = getMetadataRouteSuffix(pathnamePrefix) } @@ -76,15 +76,30 @@ export function normalizeMetadataRoute(page: string) { // If it's a metadata file route, we need to append /[id]/route to the page. if (!route.endsWith('/route')) { const { dir, name: baseName, ext } = path.parse(route) - const isStaticRoute = isStaticMetadataRoute(page) - route = path.posix.join( dir, `${baseName}${suffix ? `-${suffix}` : ''}${ext}`, - isStaticRoute ? '' : '[[...__metadata_id__]]', 'route' ) } return route } + +// Normalize metadata route page to either a single route or a dynamic route. +// e.g. Input: /sitemap/route +// when isDynamic is false, single route -> /sitemap.xml/route +// when isDynamic is false, dynamic route -> /sitemap/[__metadata_id__]/route +// also works for pathname such as /sitemap -> /sitemap.xml, but will not append /route suffix +export function normalizeMetadataPageToRoute(page: string, isDynamic: boolean) { + const isRoute = page.endsWith('/route') + const routePagePath = isRoute ? page.slice(0, -'/route'.length) : page + const metadataRouteExtension = routePagePath.endsWith('/sitemap') + ? '.xml' + : '' + const mapped = isDynamic + ? `${routePagePath}/[__metadata_id__]` + : `${routePagePath}${metadataRouteExtension}` + + return mapped + (isRoute ? '/route' : '') +} diff --git a/packages/next/src/lib/metadata/is-metadata-route.test.ts b/packages/next/src/lib/metadata/is-metadata-route.test.ts new file mode 100644 index 0000000000000..99c520fd94b7a --- /dev/null +++ b/packages/next/src/lib/metadata/is-metadata-route.test.ts @@ -0,0 +1,45 @@ +import { getExtensionRegexString } from './is-metadata-route' + +describe('getExtensionRegexString', () => { + function createExtensionMatchRegex( + ...args: Parameters + ) { + return new RegExp(`^${getExtensionRegexString(...args)}$`) + } + + describe('with dynamic extensions', () => { + it('should return the correct regex', () => { + const regex = createExtensionMatchRegex(['png', 'jpg'], ['tsx', 'ts']) + expect(regex.test('.png')).toBe(true) + expect(regex.test('.jpg')).toBe(true) + expect(regex.test('.webp')).toBe(false) + + expect(regex.test('.tsx')).toBe(true) + expect(regex.test('.ts')).toBe(true) + expect(regex.test('.js')).toBe(false) + }) + + it('should match dynamic multi-routes with dynamic extensions', () => { + const regex = createExtensionMatchRegex(['png'], ['ts']) + expect(regex.test('.png')).toBe(true) + expect(regex.test('[].png')).toBe(false) + + expect(regex.test('.ts')).toBe(true) + expect(regex.test('[].ts')).toBe(true) + expect(regex.test('.tsx')).toBe(false) + expect(regex.test('[].tsx')).toBe(false) + }) + }) + + describe('without dynamic extensions', () => { + it('should return the correct regex', () => { + const regex = createExtensionMatchRegex(['png', 'jpg'], null) + expect(regex.test('.png')).toBe(true) + expect(regex.test('.jpg')).toBe(true) + expect(regex.test('.webp')).toBe(false) + + expect(regex.test('.tsx')).toBe(false) + expect(regex.test('.js')).toBe(false) + }) + }) +}) diff --git a/packages/next/src/lib/metadata/is-metadata-route.ts b/packages/next/src/lib/metadata/is-metadata-route.ts index 0cc542d6cc12f..ac1e68e456e6c 100644 --- a/packages/next/src/lib/metadata/is-metadata-route.ts +++ b/packages/next/src/lib/metadata/is-metadata-route.ts @@ -28,8 +28,19 @@ export const STATIC_METADATA_IMAGES = { // TODO-METADATA: support more metadata routes with more extensions const defaultExtensions = ['js', 'jsx', 'ts', 'tsx'] -const getExtensionRegexString = (extensions: readonly string[]) => - `(?:${extensions.join('|')})` +// Match the file extension with the dynamic multi-routes extensions +// e.g. ([xml, js], null) -> can match `/sitemap.xml/route`, `sitemap.js/route` +// e.g. ([png], [ts]) -> can match `/opengrapg-image.png/route`, `/opengraph-image.ts[]/route` +export const getExtensionRegexString = ( + staticExtensions: readonly string[], + dynamicExtensions: readonly string[] | null +) => { + // If there's no possible multi dynamic routes, will not match any []. files + if (!dynamicExtensions) { + return `\\.(?:${staticExtensions.join('|')})` + } + return `(?:\\.(${staticExtensions.join('|')})|((\\[\\])?\\.(${dynamicExtensions.join('|')})))` +} // 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 @@ -46,15 +57,16 @@ export function isMetadataRouteFile( new RegExp( `^[\\\\/]robots${ withExtension - ? `\\.${getExtensionRegexString(pageExtensions.concat('txt'))}$` + ? `${getExtensionRegexString(pageExtensions.concat('txt'), null)}$` : '' }` ), new RegExp( `^[\\\\/]manifest${ withExtension - ? `\\.${getExtensionRegexString( - pageExtensions.concat('webmanifest', 'json') + ? `${getExtensionRegexString( + pageExtensions.concat('webmanifest', 'json'), + null )}$` : '' }` @@ -63,15 +75,16 @@ export function isMetadataRouteFile( new RegExp( `[\\\\/]sitemap${ withExtension - ? `\\.${getExtensionRegexString(pageExtensions.concat('xml'))}$` + ? `${getExtensionRegexString(['xml'], pageExtensions)}$` : '' }` ), new RegExp( `[\\\\/]${STATIC_METADATA_IMAGES.icon.filename}\\d?${ withExtension - ? `\\.${getExtensionRegexString( - pageExtensions.concat(STATIC_METADATA_IMAGES.icon.extensions) + ? `${getExtensionRegexString( + STATIC_METADATA_IMAGES.icon.extensions, + pageExtensions )}$` : '' }` @@ -79,8 +92,9 @@ export function isMetadataRouteFile( new RegExp( `[\\\\/]${STATIC_METADATA_IMAGES.apple.filename}\\d?${ withExtension - ? `\\.${getExtensionRegexString( - pageExtensions.concat(STATIC_METADATA_IMAGES.apple.extensions) + ? `${getExtensionRegexString( + STATIC_METADATA_IMAGES.apple.extensions, + pageExtensions )}$` : '' }` @@ -88,8 +102,9 @@ export function isMetadataRouteFile( new RegExp( `[\\\\/]${STATIC_METADATA_IMAGES.openGraph.filename}\\d?${ withExtension - ? `\\.${getExtensionRegexString( - pageExtensions.concat(STATIC_METADATA_IMAGES.openGraph.extensions) + ? `${getExtensionRegexString( + STATIC_METADATA_IMAGES.openGraph.extensions, + pageExtensions )}$` : '' }` @@ -97,8 +112,9 @@ export function isMetadataRouteFile( new RegExp( `[\\\\/]${STATIC_METADATA_IMAGES.twitter.filename}\\d?${ withExtension - ? `\\.${getExtensionRegexString( - pageExtensions.concat(STATIC_METADATA_IMAGES.twitter.extensions) + ? `${getExtensionRegexString( + STATIC_METADATA_IMAGES.twitter.extensions, + pageExtensions )}$` : '' }` @@ -115,6 +131,7 @@ export function isStaticMetadataRouteFile(appDirRelativePath: string) { return isMetadataRouteFile(appDirRelativePath, [], true) } +// @deprecated export function isStaticMetadataRoute(page: string) { return ( page === '/robots' || diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 9619bf2c28e4f..c09e7be9ea984 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -1,6 +1,6 @@ import type { Socket } from 'net' import { mkdir, writeFile } from 'fs/promises' -import { join } from 'path' +import { join, extname } from 'path' import ws from 'next/dist/compiled/ws' @@ -63,6 +63,7 @@ import { type TopLevelIssuesMap, isWellKnownError, printNonFatalIssue, + normalizeAppMetadataRoutePage, } from './turbopack-utils' import { propagateServerField, @@ -807,9 +808,13 @@ export async function createHotReloaderTurbopack( await currentEntriesHandling const isInsideAppDir = routeDef.bundlePath.startsWith('app/') + const normalizedAppPage = normalizeAppMetadataRoutePage( + page, + extname(routeDef.filename) + ) const route = isInsideAppDir - ? currentEntrypoints.app.get(page) + ? currentEntrypoints.app.get(normalizedAppPage) : currentEntrypoints.page.get(page) if (!route) { diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index f55a9f5ece216..d6e5130c7948f 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -583,9 +583,9 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { ]) ) - this.pagesMapping = webpackConfigSpan + this.pagesMapping = await webpackConfigSpan .traceChild('create-pages-mapping') - .traceFn(() => + .traceAsyncFn(() => createPagesMapping({ isDev: true, pageExtensions: this.config.pageExtensions, @@ -594,6 +594,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { (i: string | null): i is string => typeof i === 'string' ), pagesDir: this.pagesDir, + appDir: this.appDir, }) ) diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index 7167fedcbcf87..564eea33c8e59 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -33,6 +33,7 @@ import { } from './turbopack/entry-key' import type ws from 'next/dist/compiled/ws' import isInternal from '../../shared/lib/is-internal' +import { isMetadataRoute } from '../../lib/metadata/is-metadata-route' export async function getTurbopackJsConfig( dir: string, @@ -519,7 +520,10 @@ export async function handleRouteType({ const type = writtenEndpoint?.type - await manifestLoader.loadAppPathsManifest(page) + await manifestLoader.loadAppPathsManifest( + normalizeAppMetadataRoutePage(page, false) + ) + if (type === 'edge') { await manifestLoader.loadMiddlewareManifest(page, 'app') } else { @@ -995,3 +999,27 @@ export async function handlePagesErrorRoute({ pageEntrypoints: entrypoints.page, }) } + +export function normalizeAppMetadataRoutePage( + route: string, + ext: string | false +): string { + let entrypointKey = route + if (isMetadataRoute(entrypointKey)) { + entrypointKey = entrypointKey.endsWith('/route') + ? entrypointKey.slice(0, -'/route'.length) + : entrypointKey + + if (ext) { + if (entrypointKey.endsWith('/[__metadata_id__]')) { + entrypointKey = entrypointKey.slice(0, -'/[__metadata_id__]'.length) + } + if (entrypointKey.endsWith('/sitemap.xml') && ext !== '.xml') { + // For dynamic sitemap route, remove the extension + entrypointKey = entrypointKey.slice(0, -'.xml'.length) + } + } + entrypointKey = entrypointKey + '/route' + } + return entrypointKey +} diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index 79fbba8c00c41..ad1ac8fa5457e 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -1,7 +1,10 @@ import type { NextConfigComplete } from '../../config-shared' import type { FilesystemDynamicRoute } from './filesystem' import type { UnwrapPromise } from '../../../lib/coalesced-function' -import type { MiddlewareMatcher } from '../../../build/analysis/get-page-static-info' +import { + getPageStaticInfo, + type MiddlewareMatcher, +} from '../../../build/analysis/get-page-static-info' import type { MiddlewareRouteMatch } from '../../../shared/lib/router/utils/middleware-route-matcher' import type { PropagateToWorkersField } from './types' import type { NextJsHotReloaderInterface } from '../../dev/hot-reloader-types' @@ -77,6 +80,8 @@ import { getErrorSource } from '../../../shared/lib/error-source' import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' import { generateEncryptionKeyBase64 } from '../../app-render/encryption-utils' import { ModuleBuildError } from '../../dev/turbopack-utils' +import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route' +import { normalizeMetadataPageToRoute } from '../../../lib/metadata/get-metadata-route' export type SetupOpts = { renderServer: LazyRenderServerInstance @@ -397,6 +402,32 @@ async function startWatcher(opts: SetupOpts) { pagesType: isAppPath ? PAGE_TYPES.APP : PAGE_TYPES.PAGES, }) + if (isAppPath && isMetadataRoute(pageName)) { + const staticInfo = await getPageStaticInfo({ + pageFilePath: fileName, + nextConfig: {}, + page: pageName, + isDev: true, + pageType: PAGE_TYPES.APP, + }) + + pageName = normalizeMetadataPageToRoute( + pageName, + !!(staticInfo.generateSitemaps || staticInfo.generateImageMetadata) + ) + + // pageName = pageName.slice(0, -'/route'.length) + // if (pageName.endsWith('/sitemap')) { + + // if (staticInfo.generateSitemaps) { + // pageName = `${pageName}/[__metadata_id__]` + // } else { + // pageName = `${pageName}.xml` + // } + // } + // pageName = `${pageName}/route` + } + if ( !isAppPath && pageName.startsWith('/api/') && diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts index bd681c7ab378f..1a236aa8406a7 100644 --- a/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts +++ b/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts @@ -5,6 +5,11 @@ import { RouteKind } from '../../route-kind' import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' import { isAppRouteRoute } from '../../../lib/is-app-route-route' import { DevAppNormalizers } from '../../normalizers/built/app' +import { + isMetadataRoute, + isStaticMetadataRoute, +} from '../../../lib/metadata/is-metadata-route' +import { normalizeMetadataPageToRoute } from '../../../lib/metadata/get-metadata-route' export class DevAppRouteRouteMatcherProvider extends FileCacheRouteMatcherProvider { private readonly normalizers: { @@ -39,15 +44,67 @@ export class DevAppRouteRouteMatcherProvider extends FileCacheRouteMatcherProvid const pathname = this.normalizers.pathname.normalize(filename) const bundlePath = this.normalizers.bundlePath.normalize(filename) - matchers.push( - new AppRouteRouteMatcher({ - kind: RouteKind.APP_ROUTE, - pathname, - page, - bundlePath, - filename, - }) - ) + if (isMetadataRoute(page) && !isStaticMetadataRoute(page)) { + // Matching dynamic metadata routes. + // Add 2 possibilities for both single and multiple routes: + { + // single: + // /sitemap.ts -> /sitemap.xml/route + // /icon.ts -> /icon/route + // We'll map the filename before normalization: + // sitemap.ts -> sitemap.xml/route.ts + // icon.ts -> icon/route.ts + const metadataPage = normalizeMetadataPageToRoute(page, false) // this.normalizers.page.normalize(dummyFilename) + const metadataPathname = normalizeMetadataPageToRoute(pathname, false) // this.normalizers.pathname.normalize(dummyFilename) + const metadataBundlePath = normalizeMetadataPageToRoute( + bundlePath, + false + ) // this.normalizers.bundlePath.normalize(dummyFilename) + + const matcher = new AppRouteRouteMatcher({ + kind: RouteKind.APP_ROUTE, + page: metadataPage, + pathname: metadataPathname, + bundlePath: metadataBundlePath, + filename, + }) + matchers.push(matcher) + } + { + // multiple: + // /sitemap.ts -> /sitemap/[__metadata_id__]/route + // /icon.ts -> /icon/[__metadata_id__]/route + // We'll map the filename before normalization: + // sitemap.ts -> sitemap.xml/[__metadata_id__].ts + // icon.ts -> icon/[__metadata_id__].ts + const metadataPage = normalizeMetadataPageToRoute(page, true) + const metadataPathname = normalizeMetadataPageToRoute(pathname, true) + const metadataBundlePath = normalizeMetadataPageToRoute( + bundlePath, + true + ) + + const matcher = new AppRouteRouteMatcher({ + kind: RouteKind.APP_ROUTE, + page: metadataPage, + pathname: metadataPathname, + bundlePath: metadataBundlePath, + filename, + }) + matchers.push(matcher) + } + } else { + // Normal app routes and static metadata routes. + matchers.push( + new AppRouteRouteMatcher({ + kind: RouteKind.APP_ROUTE, + page, + pathname, + bundlePath, + filename, + }) + ) + } } return matchers diff --git a/test/development/acceptance-app/dynamic-metadata-error.test.ts b/test/development/acceptance-app/dynamic-metadata-error.test.ts index 4c478d31d442b..abbaad9051dba 100644 --- a/test/development/acceptance-app/dynamic-metadata-error.test.ts +++ b/test/development/acceptance-app/dynamic-metadata-error.test.ts @@ -71,7 +71,7 @@ describe('dynamic metadata error', () => { const { cleanup } = await sandbox( next, new Map([[sitemapFilePath, contentMissingIdProperty]]), - '/metadata-base/unset/sitemap/100' + '/metadata-base/unset/sitemap/100.xml' ) await retry(async () => { diff --git a/test/e2e/app-dir/dynamic-in-generate-params/index.test.ts b/test/e2e/app-dir/dynamic-in-generate-params/index.test.ts index d43ffdf9fad4b..14f2d55395da1 100644 --- a/test/e2e/app-dir/dynamic-in-generate-params/index.test.ts +++ b/test/e2e/app-dir/dynamic-in-generate-params/index.test.ts @@ -18,15 +18,15 @@ describe('app-dir - dynamic in generate params', () => { }) it('should render sitemap with generateSitemaps in force-dynamic config dynamically', async () => { - const firstTime = await getLastModifiedTime(next, 'sitemap/0') - const secondTime = await getLastModifiedTime(next, 'sitemap/0') + const firstTime = await getLastModifiedTime(next, 'sitemap/0.xml') + const secondTime = await getLastModifiedTime(next, 'sitemap/0.xml') expect(firstTime).not.toEqual(secondTime) }) it('should be able to call while generating multiple dynamic sitemaps', async () => { - const res0 = await next.fetch('sitemap/0') - const res1 = await next.fetch('sitemap/1') + const res0 = await next.fetch('sitemap/0.xml') + const res1 = await next.fetch('sitemap/1.xml') assertSitemapResponse(res0) assertSitemapResponse(res1) }) diff --git a/test/e2e/app-dir/logging/fetch-logging.test.ts b/test/e2e/app-dir/logging/fetch-logging.test.ts index 05076470055f8..04df20d2699dd 100644 --- a/test/e2e/app-dir/logging/fetch-logging.test.ts +++ b/test/e2e/app-dir/logging/fetch-logging.test.ts @@ -225,7 +225,7 @@ describe('app-dir - logging', () => { const output = stripAnsi(next.cliOutput.slice(logLength)) expect(output).toContain('/dynamic/[slug]/icon') expect(output).not.toContain('/(group)') - expect(output).not.toContain('[[...__metadata_id__]]') + expect(output).not.toContain('[__metadata_id__]') expect(output).not.toContain('/route') }) }) diff --git a/test/e2e/app-dir/metadata-dynamic-routes/app/gsp/icon.tsx b/test/e2e/app-dir/metadata-dynamic-routes/app/gsp/icon.tsx new file mode 100644 index 0000000000000..11c40f86bc1f0 --- /dev/null +++ b/test/e2e/app-dir/metadata-dynamic-routes/app/gsp/icon.tsx @@ -0,0 +1,37 @@ +import { ImageResponse } from 'next/og' + +export async function generateImageMetadata({ params }) { + return [ + { + contentType: 'image/png', + size: { width: 48, height: 48 }, + id: 'small', + }, + { + contentType: 'image/png', + size: { width: 72, height: 72 }, + id: 'medium', + }, + ] +} + +export default function icon({ params, id }) { + return new ImageResponse( + ( +
+ Icon {params.size} {id} +
+ ) + ) +} diff --git a/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts b/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts index 9e66abed21d09..2dca3ba618dbc 100644 --- a/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts +++ b/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts @@ -44,7 +44,7 @@ describe('app dir - metadata dynamic routes', () => { describe('sitemap', () => { it('should handle sitemap.[ext] dynamic routes', async () => { - const res = await next.fetch('/sitemap') + const res = await next.fetch('/sitemap.xml') const text = await res.text() expect(res.headers.get('content-type')).toBe('application/xml') @@ -70,23 +70,30 @@ describe('app dir - metadata dynamic routes', () => { it('should support generate multi sitemaps with generateSitemaps', async () => { const ids = ['child0', 'child1', 'child2', 'child3'] - function fetchSitemap(id) { - return next.fetch(`/gsp/sitemap/${id}`).then((res) => res.text()) + function fetchSitemap(id, withExtension) { + return next.fetch(`/gsp/sitemap/${id}${withExtension ? `.xml` : ''}`) } + // Required to have .xml extension for dynamic sitemap for (const id of ids) { - const text = await fetchSitemap(id) + const text = await fetchSitemap(id, true).then((res) => res.text()) expect(text).toContain(`https://example.com/dynamic/${id}`) } + + // Should 404 when missing .xml extension + for (const id of ids) { + const { status } = await fetchSitemap(id, false) + expect(status).toBe(404) + } }) it('should not throw if client components are imported but not used in sitemap', async () => { - const { status } = await next.fetch('/client-ref-dependency/sitemap') + const { status } = await next.fetch('/client-ref-dependency/sitemap.xml') expect(status).toBe(200) }) it('should support alternate.languages in sitemap', async () => { - const xml = await (await next.fetch('/lang/sitemap')).text() + const xml = await (await next.fetch('/lang/sitemap.xml')).text() expect(xml).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml') expect(xml).toContain( @@ -105,19 +112,19 @@ describe('app dir - metadata dynamic routes', () => { expect(appPathsManifest).toMatchObject({ // static routes '/twitter-image/route': 'app/twitter-image/route.js', - '/sitemap/route': 'app/sitemap/route.js', + '/sitemap.xml/route': 'app/sitemap.xml/route.js', // dynamic '/gsp/sitemap/[__metadata_id__]/route': 'app/gsp/sitemap/[__metadata_id__]/route.js', - '/(group)/dynamic/[size]/apple-icon-ahg52g/[[...__metadata_id__]]/route': - 'app/(group)/dynamic/[size]/apple-icon-ahg52g/[[...__metadata_id__]]/route.js', + '/(group)/dynamic/[size]/apple-icon-ahg52g/[__metadata_id__]/route': + 'app/(group)/dynamic/[size]/apple-icon-ahg52g/[__metadata_id__]/route.js', }) }) it('should generate static paths of dynamic sitemap in production', async () => { const sitemapPaths = ['child0', 'child1', 'child2', 'child3'].map( - (id) => `.next/server/app/gsp/sitemap/${id}.meta` + (id) => `.next/server/app/gsp/sitemap/${id}.xml.meta` ) const promises = sitemapPaths.map(async (filePath) => { expect(await next.hasFile(filePath)).toBe(true) @@ -183,9 +190,7 @@ describe('app dir - metadata dynamic routes', () => { const entryKeys = Object.keys(appPathsManifest) // Only has one route for twitter-image with catch-all routes in dev expect(entryKeys).not.toContain('/twitter-image') - expect(entryKeys).toContain( - '/twitter-image/[[...__metadata_id__]]/route' - ) + expect(entryKeys).toContain('/twitter-image/route') } // edge runtime @@ -316,7 +321,7 @@ describe('app dir - metadata dynamic routes', () => { if (isNextStart) { describe('route segment config', () => { it('should generate dynamic route if dynamic config is force-dynamic', async () => { - const dynamicRoute = '/route-config/sitemap' + const dynamicRoute = '/route-config/sitemap.xml' expect( await next.hasFile(`.next/server/app${dynamicRoute}/route.js`) @@ -453,7 +458,7 @@ describe('app dir - metadata dynamic routes', () => { it('should include default og font files in file trace', async () => { const fileTrace = JSON.parse( await next.readFile( - '.next/server/app/metadata-base/unset/opengraph-image2/[[...__metadata_id__]]/route.js.nft.json' + '.next/server/app/metadata-base/unset/opengraph-image2/[__metadata_id__]/route.js.nft.json' ) ) @@ -463,5 +468,15 @@ describe('app dir - metadata dynamic routes', () => { ) expect(isTraced).toBe(true) }) + + it('should statically optimized single image route', async () => { + const prerenderManifest = JSON.parse( + await next.readFile('.next/prerender-manifest.json') + ) + const dynamicRoutes = Object.keys(prerenderManifest.routes) + expect(dynamicRoutes).toContain('/opengraph-image') + expect(dynamicRoutes).toContain('/opengraph-image-1ow20b') + expect(dynamicRoutes).toContain('/apple-icon') + }) } }) diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index f03f72937d385..8b63a5f15da04 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -958,7 +958,7 @@ describe('app dir - metadata', () => { ).toBe(true) expect( await next.hasFile( - '.next/server/app/opengraph/static/opengraph-image.png/[[...__metadata_id__]]/route.js' + '.next/server/app/opengraph/static/opengraph-image.png/[__metadata_id__]/route.js' ) ).toBe(false) }) diff --git a/test/production/app-dir/metadata-static/metadata-static.test.ts b/test/production/app-dir/metadata-static/metadata-static.test.ts index bd1cf476f2aa7..104160a21ce9d 100644 --- a/test/production/app-dir/metadata-static/metadata-static.test.ts +++ b/test/production/app-dir/metadata-static/metadata-static.test.ts @@ -12,7 +12,7 @@ describe('app dir - metadata', () => { for (const key of [ '/robots.txt', - '/sitemap', + '/sitemap.xml', '/opengraph-image', '/manifest.webmanifest', ]) { diff --git a/test/turbopack-build-tests-manifest.json b/test/turbopack-build-tests-manifest.json index a104fee5b7388..a004ecf5807c3 100644 --- a/test/turbopack-build-tests-manifest.json +++ b/test/turbopack-build-tests-manifest.json @@ -1924,7 +1924,8 @@ "app dir - metadata dynamic routes social image routes should render og image with opengraph-image dynamic routes", "app dir - metadata dynamic routes social image routes should render og image with twitter-image dynamic routes", "app dir - metadata dynamic routes social image routes should support generate multi images with generateImageMetadata", - "app dir - metadata dynamic routes social image routes should support params as argument in dynamic routes" + "app dir - metadata dynamic routes social image routes should support params as argument in dynamic routes", + "app dir - metadata dynamic routes should statically optimized single image route" ], "pending": [], "flakey": [],