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:
parent
544fc0acdf
commit
f893c18528
28 changed files with 566 additions and 298 deletions
|
@ -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.
|
||||
|
||||
|
|
|
@ -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, ¶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 {
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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) {{
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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' : '')
|
||||
}
|
||||
|
|
45
packages/next/src/lib/metadata/is-metadata-route.test.ts
Normal file
45
packages/next/src/lib/metadata/is-metadata-route.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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' ||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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/') &&
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
37
test/e2e/app-dir/metadata-dynamic-routes/app/gsp/icon.tsx
Normal file
37
test/e2e/app-dir/metadata-dynamic-routes/app/gsp/icon.tsx
Normal 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>
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -12,7 +12,7 @@ describe('app dir - metadata', () => {
|
|||
|
||||
for (const key of [
|
||||
'/robots.txt',
|
||||
'/sitemap',
|
||||
'/sitemap.xml',
|
||||
'/opengraph-image',
|
||||
'/manifest.webmanifest',
|
||||
]) {
|
||||
|
|
|
@ -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": [],
|
||||
|
|
Loading…
Reference in a new issue