Append sitemap extension and optimize imafe metadata static generation (#66477)

### What

Optimizing the static generation for dynamic metadata routes

If you're not using `generateSitemaps()` or `generateSitemaps()`, you
don't need to change any file conventions.
If you're using multi sitemap routes, make sure the returned `id`
properties from `generateSitemaps()` don't need to contain `.xml`, since
we'll always append one for you.

Analyzing the exports of metadata routes and determine if we need to
make them as dynamic routes.

### Why

Previously, users are struggling with the multi routes of sitemap or
images.
For sitemap, the `.xml` extension in url doesn't get appended
consistently to the multi sitemap route between dev and prod.
For image routes, the generated image routes are always dynamic routes
which cannot get static optimized.

The reason is that we need to always generate a catch-all route (such as
`/icon/[[...id]]` to handle both single route case (e.g. without
`generateImageMetadata`, representing url `/icon`) or multi route (e.g.
with `generateImageMetadata`, representing url `/icon/[id]`), only
catch-all routes can do it. This approach fail the static optimization
and make mapping url pretty difficult as parsing the file to check the
module exports has to be done before it.

#### Benifits

For image routes urls, this approach could help on static generation
such as single `/opengraph-image` route can be treated as static, and
then it can get static optimized if possible.

**Before**: `/opengraph-image/[[...id]]` cannot be optimized
**After**: single route `/opengraph-image` and multi-route
`/opengraph-image/[id]` are both possible to be statically optimized

For sitemap, since we removed appending `.xml` for dynamic routes, it’s
hard for users to have `/sitemap.xml` url with dynamic route convention
`sitemap.js` . But users desire smooth migration and flexibility.

**Before**: In v15 rc we removed the `.xml` appending that `sitemap.js`
will generate url `/sitemap` makes users hard to migrate, as users need
to re-submit the new sitemap url.
**After**: Now we'll consistently generate the `.xml`. Single route will
become `/sitemap.xml`, and multi route will become `/sitemap/[id].xml`.
It's still better than v15 as the urls generation is consistent, no
difference between dev and prod.

Here's the url generation comparsion

#### Before

All the routes are dynamic which cannot be optimized, we only had a
hacky optimization for prodution build multi-routes sitemap routes

| | only default export | `export generateImageMetadata()` | `export
generateSitemaps()` |
| -- | -- | -- | -- |
| opengraph-image.js | /opengraph-image/[[...id]] |
/opengraph-image[[...id]]/ | /opengraph-image/[[...id]] |
| sitemap.js | /sitemap/[[...id]] | /sitemap/[[...id]] | dev:
`/sitemap/[[...id]]` prod: `/sitemap/[id]` |

#### After

Most of the single route will are to get statically optimized now, and
the multi-routes sitemap are able to get SSG now

| | only default export | `export generateImageMetadata()` | `export
generateSitemaps()` |
| -- | -- | -- | -- |
| opengraph-image.js | /opengraph-image | /opengraph-image/[id] | - |
| sitemap.js | /sitemap.xml | - | /sitemap/[id].xml |

Next.js will have less overhead of mapping urls, we can easily multiply
the urls generation simply based on file conventions.

x-ref: feedback from #65507 
Closes #66232
This commit is contained in:
Jiachi Liu 2024-06-10 17:34:06 +02:00 committed by GitHub
parent 544fc0acdf
commit f893c18528
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 566 additions and 298 deletions

View file

@ -97,7 +97,7 @@ export default function sitemap() {
Output:
```xml filename="acme.com/sitemap"
```xml filename="acme.com/sitemap.xml"
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://acme.com</loc>
@ -163,7 +163,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
Output:
```xml filename="acme.com/sitemap"
```xml filename="acme.com/sitemap.xml"
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://acme.com</loc>
@ -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.

View file

@ -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<NextRuntime>,
pub preferred_region: Option<Vec<RcStr>>,
pub experimental_ppr: Option<bool>,
/// 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, &parallel_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 {

View file

@ -306,8 +306,7 @@ pub fn normalize_metadata_route(mut page: AppPage) -> Result<AppPage> {
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<AppPage> {
// /<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(&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<AppPage> {
.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"],

View file

@ -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<Vc<Box<dyn Source>>> {
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<ModuleAssetContext>,
edge_context: Vc<ModuleAssetContext>,
project_root: Vc<FileSystemPath>,
page: AppPage,
mut page: AppPage,
mode: NextMode,
metadata: MetadataItem,
next_config: Vc<NextConfig>,
@ -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<FileSystemPath>) -> Result<Vc<Box<dy
async fn dynamic_site_map_route_source(
mode: NextMode,
path: Vc<FileSystemPath>,
page: AppPage,
is_multi_dynamic: bool,
) -> 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.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
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 {{ __metadata_id__: id, ...params }} = ctx.params || {{}}
const hasXmlExtension = id ? id.endsWith('.xml') : false
if (id && !hasXmlExtension) {{
return new NextResponse('Not Found', {{
status: 404,
}})
}}
const data = await handler({{ id }})
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')
}}
}}
}}
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<FileSystemPath>) -> Result<Vc<Box<d
}}
export async function GET(_, ctx) {{
const {{ __metadata_id__ = [], ...params }} = ctx.params || {{}}
const targetId = __metadata_id__[0]
const {{ __metadata_id__, ...params }} = ctx.params || {{}}
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) {{

View file

@ -59,6 +59,8 @@ export interface PageStaticInfo {
ssr?: boolean
rsc?: RSCModuleType
generateStaticParams?: boolean
generateSitemaps?: boolean
generateImageMetadata?: boolean
middleware?: MiddlewareConfigParsed
amp?: boolean | 'hybrid'
extraConfig?: Record<string, any>
@ -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<string>
directives?: Set<string>
@ -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<boolean> {
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 (
/(?<!(_jsx|jsx-))runtime|preferredRegion|getStaticProps|getServerSideProps|generateStaticParams|export const/.test(
/(?<!(_jsx|jsx-))runtime|preferredRegion|getStaticProps|getServerSideProps|generateStaticParams|export const|generateImageMetadata|generateSitemaps/.test(
fileContent
)
) {
@ -510,6 +498,8 @@ export async function getPageStaticInfo(params: {
runtime,
preferredRegion,
generateStaticParams,
generateImageMetadata,
generateSitemaps,
extraProperties,
directives,
} = checkExports(swcAST, pageFilePath)
@ -651,6 +641,8 @@ export async function getPageStaticInfo(params: {
ssg,
rsc,
generateStaticParams,
generateImageMetadata,
generateSitemaps,
amp: config.amp || false,
...(middlewareConfig && { middleware: middlewareConfig }),
...(resolvedRuntime && { runtime: resolvedRuntime }),
@ -664,6 +656,8 @@ export async function getPageStaticInfo(params: {
ssg: false,
rsc: RSC_MODULE_TYPES.server,
generateStaticParams: false,
generateImageMetadata: false,
generateSitemaps: false,
amp: false,
runtime: undefined,
}

View file

@ -55,13 +55,19 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
import { encodeMatchers } from './webpack/loaders/next-middleware-loader'
import type { EdgeFunctionLoaderOptions } from './webpack/loaders/next-edge-function-loader'
import { isAppRouteRoute } from '../lib/is-app-route-route'
import { normalizeMetadataRoute } from '../lib/metadata/get-metadata-route'
import {
normalizeMetadataPageToRoute,
normalizeMetadataRoute,
} from '../lib/metadata/get-metadata-route'
import { getRouteLoaderEntry } from './webpack/loaders/next-route-loader'
import {
isInternalComponent,
isNonRoutePagesPage,
} from '../lib/is-internal-component'
import { isStaticMetadataRouteFile } from '../lib/metadata/is-metadata-route'
import {
isMetadataRoute,
isStaticMetadataRouteFile,
} from '../lib/metadata/is-metadata-route'
import { RouteKind } from '../server/route-kind'
import { encodeToBase64 } from './webpack/loaders/utils'
import { normalizeCatchAllRoutes } from './normalize-catchall-routes'
@ -223,53 +229,70 @@ export function getPageFilePath({
* Creates a mapping of route to page file path for a given list of page paths.
* For example ['/middleware.ts'] is turned into { '/middleware': `${ROOT_DIR_ALIAS}/middleware.ts` }
*/
export function createPagesMapping({
export async function createPagesMapping({
isDev,
pageExtensions,
pagePaths,
pagesType,
pagesDir,
appDir,
}: {
isDev: boolean
pageExtensions: PageExtensions
pagePaths: string[]
pagesType: PAGE_TYPES
pagesDir: string | undefined
}): MappedPages {
appDir: string | undefined
}): Promise<MappedPages> {
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<Promise<void>>(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: {

View file

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

View file

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

View file

@ -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<string[]> {
const collectedFiles: string[] = []
// Collect <filename>.<ext>, <filename>[].<ext>
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))

View file

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

View file

@ -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<any>
) {
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__]')
) {
staticGenerationCode = `\
/* dynamic sitemap route */
export async function generateStaticParams() {
const sitemaps = generateSitemaps ? await generateSitemaps() : []
const params = []
const hasGenerateSitemaps = exportNames.includes('generateSitemaps')
for (const item of sitemaps) {
params.push({ __metadata_id__: item.id.toString() })
}
return params
}
if (process.env.NODE_ENV === 'production' && hasGenerateSitemaps) {
staticGenerationCode = `\
/* 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() + '.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 { __metadata_id__: id, ...params } = ctx.params || {}
const hasXmlExtension = id ? id.endsWith('.xml') : false
if (id && !hasXmlExtension) {
return new NextResponse('Not Found', {
status: 404,
})
}
const targetId = process.env.NODE_ENV !== 'production'
? __metadata_id__?.[0]
: __metadata_id__
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')
}
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<MetadataRouteLoaderOptions> =
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)
}

View file

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

View file

@ -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' : '')
}

View file

@ -0,0 +1,45 @@
import { getExtensionRegexString } from './is-metadata-route'
describe('getExtensionRegexString', () => {
function createExtensionMatchRegex(
...args: Parameters<typeof getExtensionRegexString>
) {
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)
})
})
})

View file

@ -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 <name>[].<ext> 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' ||

View file

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

View file

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

View file

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

View file

@ -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/') &&

View file

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

View file

@ -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 () => {

View file

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

View file

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

View file

@ -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(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 88,
background: '#fff',
color: '#000',
}}
>
Icon {params.size} {id}
</div>
)
)
}

View file

@ -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(`<loc>https://example.com/dynamic/${id}</loc>`)
}
// 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')
})
}
})

View file

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

View file

@ -12,7 +12,7 @@ describe('app dir - metadata', () => {
for (const key of [
'/robots.txt',
'/sitemap',
'/sitemap.xml',
'/opengraph-image',
'/manifest.webmanifest',
]) {

View file

@ -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": [],