Skip to content

Commit

Permalink
Support generate dynamic sitemaps for dynamic routes (#48867)
Browse files Browse the repository at this point in the history
### What

For dynamic routes you might have different sitemap for different params

* Unloack using `sitemap.[ext]` in your app everywhere
* Support `generateSitemaps()` to create multiple sitemaps at the same time

### How

* Change the metadata regex to allow use sitemap in every routes
* Similar approach to `generateImageMetadata`, we make `sitemap.js` under dynamic routes to a catch all routes, and it can have multiple routes

Closes NEXT-1054
  • Loading branch information
huozhi committed Apr 26, 2023
1 parent b6e0c35 commit a4d6309
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const imageModule = { ..._imageModule }
const handler = imageModule.default
const generateImageMetadata = imageModule.generateImageMetadata
export async function GET(req, ctx) {
export async function GET(_, ctx) {
const { __metadata_id__ = [], ...params } = ctx.params
const id = __metadata_id__[0]
const imageMetadata = generateImageMetadata ? await generateImageMetadata({ params }) : null
Expand All @@ -118,6 +118,46 @@ export async function GET(req, ctx) {
`
}

function getDynamicSiteMapRouteCode(resourcePath: string) {
// generateSitemaps
return `\
import { NextResponse } from 'next/server'
import * as _sitemapModule from ${JSON.stringify(resourcePath)}
import { resolveRouteData } from 'next/dist/build/webpack/loaders/metadata/resolve-route-data'
const sitemapModule = { ..._sitemapModule }
const handler = sitemapModule.default
const generateSitemaps = sitemapModule.generateSitemaps
const contentType = ${JSON.stringify(getContentType(resourcePath))}
const fileType = ${JSON.stringify(getFilenameAndExtension(resourcePath).name)}
export async function GET(_, ctx) {
const sitemaps = generateSitemaps ? await generateSitemaps() : null
let id = undefined
if (sitemaps) {
const { __metadata_id__ = [] } = ctx.params
const targetId = __metadata_id__[0]
id = sitemaps.find((item) => item.id.toString() === targetId)?.id
if (id == null) {
return new NextResponse(null, {
status: 404,
})
}
}
const data = await handler({ id })
const content = resolveRouteData(data, fileType)
return new NextResponse(content, {
headers: {
'Content-Type': contentType,
'Cache-Control': ${JSON.stringify(cacheHeader.revalidate)},
},
})
}
`
}
// `import.meta.url` is the resource name of the current module.
// When it's static route, it could be favicon.ico, sitemap.xml, robots.txt etc.
// TODO-METADATA: improve the cache control strategy
Expand All @@ -131,12 +171,10 @@ const nextMetadataRouterLoader: webpack.LoaderDefinitionFunction<MetadataRouteLo

let code = ''
if (isDynamic) {
if (
fileBaseName === 'sitemap' ||
fileBaseName === 'robots' ||
fileBaseName === 'manifest'
) {
if (fileBaseName === 'robots' || fileBaseName === 'manifest') {
code = getDynamicTextRouteCode(resourcePath)
} else if (fileBaseName === 'sitemap') {
code = getDynamicSiteMapRouteCode(resourcePath)
} else {
code = getDynamicImageRouteCode(resourcePath)
}
Expand Down
33 changes: 18 additions & 15 deletions packages/next/src/lib/metadata/get-metadata-route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isMetadataRoute, isMetadataRouteFile } from './is-metadata-route'
import path from '../../shared/lib/isomorphic/path'
import { djb2Hash } from '../../shared/lib/hash'
import { isDynamicRoute } from '../../shared/lib/router/utils'

/*
* If there's special convention like (...) or @ in the page path,
Expand Down Expand Up @@ -30,36 +31,38 @@ export function getMetadataRouteSuffix(page: string) {
*/
export function normalizeMetadataRoute(page: string) {
let route = page
let suffix = ''
if (isMetadataRoute(page)) {
// Remove the file extension, e.g. /route-path/robots.txt -> /route-path
const pathnamePrefix = page.slice(0, -(path.basename(page).length + 1))
const suffix = getMetadataRouteSuffix(pathnamePrefix)

if (route === '/sitemap') {
route += '.xml'
}
if (route === '/robots') {
route += '.txt'
}
if (route === '/manifest') {
} else if (route === '/manifest') {
route += '.webmanifest'
} else if (route.endsWith('/sitemap')) {
route += '.xml'
} else {
// Remove the file extension, e.g. /route-path/robots.txt -> /route-path
const pathnamePrefix = page.slice(0, -(path.basename(page).length + 1))
suffix = getMetadataRouteSuffix(pathnamePrefix)
}
// Support both /<metadata-route.ext> and custom routes /<metadata-route>/route.ts.
// If it's a metadata file route, we need to append /[id]/route to the page.
if (!route.endsWith('/route')) {
const isStaticMetadataFile = isMetadataRouteFile(route, [], true)
const { dir, name: baseName, ext } = path.parse(route)

const isSingleRoute =
page.startsWith('/sitemap') ||
page.startsWith('/robots') ||
page.startsWith('/manifest') ||
isStaticMetadataFile
// If it's dynamic routes, we need to append [[...__metadata_id__]] to the page;
// If it's static routes, we need to append nothing to the page.
// If its special routes like robots.txt and manifest.webmanifest, we leave them as static routes.
const isStaticRoute =
!isDynamicRoute(route) &&
(page.startsWith('/robots') ||
page.startsWith('/manifest') ||
isStaticMetadataFile)

route = path.posix.join(
dir,
`${baseName}${suffix ? `-${suffix}` : ''}${ext}`,
isSingleRoute ? '' : '[[...__metadata_id__]]',
isStaticRoute ? '' : '[[...__metadata_id__]]',
'route'
)
}
Expand Down
15 changes: 7 additions & 8 deletions packages/next/src/lib/metadata/is-metadata-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,6 @@ export function isMetadataRouteFile(
: ''
}`
),
new RegExp(
`^[\\\\/]sitemap${
withExtension
? `\\.${getExtensionRegexString(pageExtensions.concat('xml'))}`
: ''
}`
),
new RegExp(
`^[\\\\/]manifest${
withExtension
Expand All @@ -64,7 +57,13 @@ export function isMetadataRouteFile(
}`
),
new RegExp(`^[\\\\/]favicon\\.ico$`),
// TODO-METADATA: add dynamic routes for metadata images
new RegExp(
`[\\\\/]sitemap${
withExtension
? `\\.${getExtensionRegexString(pageExtensions.concat('xml'))}`
: ''
}`
),
new RegExp(
`[\\\\/]${STATIC_METADATA_IMAGES.icon.filename}${
withExtension
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { MetadataRoute } from 'next'

export async function generateSitemaps() {
return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }]
}

export default function sitemap({ id }): MetadataRoute.Sitemap {
return [
{
url: `https://example.com/dynamic/${id}`,
lastModified: '2021-01-01',
},
{
url: `https://example.com/dynamic/${id}/about`,
lastModified: '2021-01-01',
},
]
}
14 changes: 14 additions & 0 deletions test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ createNextDescribe(
])
})

it('should support generate multi sitemaps with generateSitemaps', async () => {
const ids = [0, 1, 2, 3]
function fetchSitemap(id) {
return next
.fetch(`/dynamic/small/sitemap.xml/${id}`)
.then((res) => res.text())
}

for (const id of ids) {
const text = await fetchSitemap(id)
expect(text).toContain(`<loc>https://example.com/dynamic/${id}</loc>`)
}
})

it('should fill params into dynamic routes url of metadata images', async () => {
const $ = await next.render$('/dynamic/big')
const ogImageUrl = $('meta[property="og:image"]').attr('content')
Expand Down
24 changes: 0 additions & 24 deletions test/e2e/app-dir/metadata-dynamic-routes/tsconfig.json

This file was deleted.

4 changes: 2 additions & 2 deletions test/e2e/app-dir/metadata/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ createNextDescribe(
expect(invalidRobotsResponse.status).toBe(404)
})

it('should support root dir sitemap.xml', async () => {
it('should support sitemap.xml under every routes', async () => {
const res = await next.fetch('/sitemap.xml')
expect(res.headers.get('content-type')).toBe('application/xml')
const sitemap = await res.text()
Expand All @@ -753,7 +753,7 @@ createNextDescribe(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
)
const invalidSitemapResponse = await next.fetch('/title/sitemap.xml')
expect(invalidSitemapResponse.status).toBe(404)
expect(invalidSitemapResponse.status).toBe(200)
})

it('should support static manifest.webmanifest', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/find-page-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('createPageFileMatcher', () => {
expect(fileMatcher.isMetadataFile('app/path/robots.txt')).toBe(false)

expect(fileMatcher.isMetadataFile('app/sitemap.xml')).toBe(true)
expect(fileMatcher.isMetadataFile('app/path/sitemap.xml')).toBe(false)
expect(fileMatcher.isMetadataFile('app/path/sitemap.xml')).toBe(true)
})
})
})

0 comments on commit a4d6309

Please sign in to comment.