feat(nuxt): experimentally extract route metadata at build time (#25210)

Co-authored-by: Bobbie Goede <bobbiegoede@gmail.com>
This commit is contained in:
Daniel Roe 2024-01-29 16:44:54 +00:00 committed by GitHub
parent 90ca0e8797
commit 407fde6765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1049 additions and 55 deletions

View File

@ -375,6 +375,12 @@ globalThis.Buffer = globalThis.Buffer || Buffer
``` ```
:: ::
## scanPageMeta
This option allows exposing some route metadata defined in `definePageMeta` at build-time to modules (specifically `alias`, `name`, `path`, `redirect`).
This only works with static or strings/arrays rather than variables or conditional assignment. See [original issue](https://github.com/nuxt/nuxt/issues/24770) for more information and context.
## cookieStore ## cookieStore
Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values. Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values.

View File

@ -216,11 +216,17 @@ export default defineNuxtModule({
] ]
}) })
function isPage (file: string, pages = nuxt.apps.default.pages): boolean {
if (!pages) { return false }
return pages.some(page => page.file === file) || pages.some(page => page.children && isPage(file, page.children))
}
nuxt.hook('builder:watch', async (event, relativePath) => { nuxt.hook('builder:watch', async (event, relativePath) => {
if (event === 'change') { return }
const path = resolve(nuxt.options.srcDir, relativePath) const path = resolve(nuxt.options.srcDir, relativePath)
if (updateTemplatePaths.some(dir => path.startsWith(dir))) { const shouldAlwaysRegenerate = nuxt.options.experimental.scanPageMeta && isPage(path)
if (event === 'change' && !shouldAlwaysRegenerate) { return }
if (shouldAlwaysRegenerate || updateTemplatePaths.some(dir => path.startsWith(dir))) {
await updateTemplates({ await updateTemplates({
filter: template => template.filename === 'routes.mjs' filter: template => template.filename === 'routes.mjs'
}) })
@ -398,7 +404,7 @@ export default defineNuxtModule({
filename: 'routes.mjs', filename: 'routes.mjs',
getContents ({ app }) { getContents ({ app }) {
if (!app.pages) return 'export default []' if (!app.pages) return 'export default []'
const { routes, imports } = normalizeRoutes(app.pages) const { routes, imports } = normalizeRoutes(app.pages, new Set(), nuxt.options.experimental.scanPageMeta)
return [...imports, `export default ${routes}`].join('\n') return [...imports, `export default ${routes}`].join('\n')
} }
}) })

View File

@ -1,3 +1,4 @@
import { runInNewContext } from 'node:vm'
import fs from 'node:fs' import fs from 'node:fs'
import { extname, normalize, relative, resolve } from 'pathe' import { extname, normalize, relative, resolve } from 'pathe'
import { encodePath, joinURL, withLeadingSlash } from 'ufo' import { encodePath, joinURL, withLeadingSlash } from 'ufo'
@ -55,12 +56,20 @@ export async function resolvePagesRoutes (): Promise<NuxtPage[]> {
// sort scanned files using en-US locale to make the result consistent across different system locales // sort scanned files using en-US locale to make the result consistent across different system locales
scannedFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath, 'en-US')) scannedFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath, 'en-US'))
const allRoutes = await generateRoutesFromFiles(uniqueBy(scannedFiles, 'relativePath'), nuxt.options.experimental.typedPages, nuxt.vfs) const allRoutes = await generateRoutesFromFiles(uniqueBy(scannedFiles, 'relativePath'), {
shouldExtractBuildMeta: nuxt.options.experimental.scanPageMeta || nuxt.options.experimental.typedPages,
vfs: nuxt.vfs
})
return uniqueBy(allRoutes, 'path') return uniqueBy(allRoutes, 'path')
} }
export async function generateRoutesFromFiles (files: ScannedFile[], shouldExtractBuildMeta = false, vfs?: Record<string, string>): Promise<NuxtPage[]> { type GenerateRoutesFromFilesOptions = {
shouldExtractBuildMeta?: boolean
vfs?: Record<string, string>
}
export async function generateRoutesFromFiles (files: ScannedFile[], options: GenerateRoutesFromFilesOptions = {}): Promise<NuxtPage[]> {
const routes: NuxtPage[] = [] const routes: NuxtPage[] = []
for (const file of files) { for (const file of files) {
@ -101,12 +110,9 @@ export async function generateRoutesFromFiles (files: ScannedFile[], shouldExtra
} }
} }
if (shouldExtractBuildMeta && vfs) { if (options.shouldExtractBuildMeta && options.vfs) {
const fileContent = file.absolutePath in vfs ? vfs[file.absolutePath] : fs.readFileSync(file.absolutePath, 'utf-8') const fileContent = file.absolutePath in options.vfs ? options.vfs[file.absolutePath] : fs.readFileSync(file.absolutePath, 'utf-8')
const overrideRouteName = await getRouteName(fileContent) Object.assign(route, await getRouteMeta(fileContent, file.absolutePath))
if (overrideRouteName) {
route.name = overrideRouteName
}
} }
parent.push(route) parent.push(route)
@ -127,26 +133,96 @@ export function extractScriptContent (html: string) {
} }
const PAGE_META_RE = /(definePageMeta\([\s\S]*?\))/ const PAGE_META_RE = /(definePageMeta\([\s\S]*?\))/
const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const
async function getRouteName (file: string) { const metaCache: Record<string, Partial<Record<keyof NuxtPage, any>>> = {}
const script = extractScriptContent(file) async function getRouteMeta (contents: string, absolutePath?: string): Promise<Partial<Record<keyof NuxtPage, any>>> {
if (!script) { return null } if (contents in metaCache) { return metaCache[contents] }
if (!PAGE_META_RE.test(script)) { return null } const script = extractScriptContent(contents)
if (!script) {
metaCache[contents] = {}
return {}
}
if (!PAGE_META_RE.test(script)) {
metaCache[contents] = {}
return {}
}
const js = await transform(script, { loader: 'ts' }) const js = await transform(script, { loader: 'ts' })
const ast = parse(js.code, { const ast = parse(js.code, {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 'latest' ecmaVersion: 'latest',
ranges: true
}) as unknown as Program }) as unknown as Program
const pageMetaAST = ast.body.find(node => node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' && node.expression.callee.type === 'Identifier' && node.expression.callee.name === 'definePageMeta') const pageMetaAST = ast.body.find(node => node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' && node.expression.callee.type === 'Identifier' && node.expression.callee.name === 'definePageMeta')
if (!pageMetaAST) { return null } if (!pageMetaAST) {
metaCache[contents] = {}
return {}
}
const pageMetaArgument = ((pageMetaAST as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression const pageMetaArgument = ((pageMetaAST as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression
const nameProperty = pageMetaArgument.properties.find(property => property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === 'name') as Property const extractedMeta = {} as Partial<Record<keyof NuxtPage, any>>
if (!nameProperty || nameProperty.value.type !== 'Literal' || typeof nameProperty.value.value !== 'string') { return null } const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const
const dynamicProperties = new Set<keyof NuxtPage>()
return nameProperty.value.value for (const key of extractionKeys) {
const property = pageMetaArgument.properties.find(property => property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === key) as Property
if (!property) { continue }
if (property.value.type === 'ObjectExpression') {
const valueString = js.code.slice(property.value.range![0], property.value.range![1])
try {
extractedMeta[key] = JSON.parse(runInNewContext(`JSON.stringify(${valueString})`, {}))
} catch {
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not JSON-serializable (reading \`${absolutePath}\`).`)
dynamicProperties.add(key)
continue
}
}
if (property.value.type === 'ArrayExpression') {
const values = []
for (const element of property.value.elements) {
if (!element) {
continue
}
if (element.type !== 'Literal' || typeof element.value !== 'string') {
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not an array of string literals (reading \`${absolutePath}\`).`)
dynamicProperties.add(key)
continue
}
values.push(element.value)
}
extractedMeta[key] = values
continue
}
if (property.value.type !== 'Literal' || typeof property.value.value !== 'string') {
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not a string literal or array of string literals (reading \`${absolutePath}\`).`)
dynamicProperties.add(key)
continue
}
extractedMeta[key] = property.value.value
}
const extraneousMetaKeys = pageMetaArgument.properties
.filter(property => property.type === 'Property' && property.key.type === 'Identifier' && !(extractionKeys as unknown as string[]).includes(property.key.name))
// @ts-expect-error inferred types have been filtered out
.map(property => property.key.name)
if (extraneousMetaKeys.length) {
dynamicProperties.add('meta')
}
if (dynamicProperties.size) {
extractedMeta.meta ??= {}
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
}
metaCache[contents] = extractedMeta
return extractedMeta
} }
function getRoutePath (tokens: SegmentToken[]): string { function getRoutePath (tokens: SegmentToken[]): string {
@ -301,26 +377,42 @@ function prepareRoutes (routes: NuxtPage[], parent?: NuxtPage, names = new Set<s
return routes return routes
} }
export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> = new Set()): { imports: Set<string>, routes: string } { function serializeRouteValue (value: any, skipSerialisation = false) {
if (skipSerialisation || value === undefined) return undefined
return JSON.stringify(value)
}
type NormalizedRoute = Partial<Record<Exclude<keyof NuxtPage, 'file'>, string>> & { component?: string }
type NormalizedRouteKeys = (keyof NormalizedRoute)[]
export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> = new Set(), overrideMeta = false): { imports: Set<string>, routes: string } {
return { return {
imports: metaImports, imports: metaImports,
routes: genArrayFromRaw(routes.map((page) => { routes: genArrayFromRaw(routes.map((page) => {
const route: Record<Exclude<keyof NuxtPage, 'file'>, string> & { component?: string } = Object.create(null) const markedDynamic = page.meta?.[DYNAMIC_META_KEY] ?? new Set()
for (const [key, value] of Object.entries(page)) { const metaFiltered: Record<string, any> = {}
if (key !== 'file' && (Array.isArray(value) ? value.length : value)) { let skipMeta = true
route[key as Exclude<keyof NuxtPage, 'file'>] = JSON.stringify(value) for (const key in page.meta || {}) {
if (key !== DYNAMIC_META_KEY && page.meta![key] !== undefined) {
skipMeta = false
metaFiltered[key] = page.meta![key]
} }
} }
const skipAlias = toArray(page.alias).every(val => !val)
const route: NormalizedRoute = {
path: serializeRouteValue(page.path),
name: serializeRouteValue(page.name),
meta: serializeRouteValue(metaFiltered, skipMeta),
alias: serializeRouteValue(toArray(page.alias), skipAlias),
redirect: serializeRouteValue(page.redirect),
}
if (page.children?.length) { if (page.children?.length) {
route.children = normalizeRoutes(page.children, metaImports).routes route.children = normalizeRoutes(page.children, metaImports, overrideMeta).routes
} }
// Without a file, we can't use `definePageMeta` to extract route-level meta from the file // Without a file, we can't use `definePageMeta` to extract route-level meta from the file
if (!page.file) { if (!page.file) {
for (const key of ['name', 'path', 'meta', 'alias', 'redirect'] as const) {
if (page[key]) { route[key] = JSON.stringify(page[key]) }
}
return route return route
} }
@ -328,20 +420,56 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> =
const metaImportName = genSafeVariableName(filename(file) + hash(file)) + 'Meta' const metaImportName = genSafeVariableName(filename(file) + hash(file)) + 'Meta'
metaImports.add(genImport(`${file}?macro=true`, [{ name: 'default', as: metaImportName }])) metaImports.add(genImport(`${file}?macro=true`, [{ name: 'default', as: metaImportName }]))
let aliasCode = `${metaImportName}?.alias || []` const metaRoute: NormalizedRoute = {
const alias = toArray(page.alias).filter(Boolean) name: `${metaImportName}?.name ?? ${route.name}`,
if (alias.length) { path: `${metaImportName}?.path ?? ${route.path}`,
aliasCode = `${JSON.stringify(alias)}.concat(${aliasCode})` meta: `${metaImportName} || {}`,
alias: `${metaImportName}?.alias || []`,
redirect: `${metaImportName}?.redirect`,
component: genDynamicImport(file, { interopDefault: true })
} }
route.name = `${metaImportName}?.name ?? ${page.name ? JSON.stringify(page.name) : 'undefined'}` if (route.children != null) {
route.path = `${metaImportName}?.path ?? ${JSON.stringify(page.path)}` metaRoute.children = route.children
route.meta = page.meta && Object.values(page.meta).filter(value => value !== undefined).length ? `{...(${metaImportName} || {}), ...${JSON.stringify(page.meta)}}` : `${metaImportName} || {}` }
route.alias = aliasCode
route.redirect = page.redirect ? JSON.stringify(page.redirect) : `${metaImportName}?.redirect || undefined`
route.component = genDynamicImport(file, { interopDefault: true })
return route if (overrideMeta) {
metaRoute.name = `${metaImportName}?.name`
metaRoute.path = `${metaImportName}?.path ?? ''`
// skip and retain fallback if marked dynamic
// set to extracted value or fallback if none extracted
for (const key of ['name', 'path'] satisfies NormalizedRouteKeys) {
if (markedDynamic.has(key)) continue
metaRoute[key] = route[key] ?? metaRoute[key]
}
// set to extracted value or delete if none extracted
for (const key of ['meta', 'alias', 'redirect'] satisfies NormalizedRouteKeys) {
if (markedDynamic.has(key)) continue
if (route[key] == null) {
delete metaRoute[key]
continue
}
metaRoute[key] = route[key]
}
} else {
if (route.meta != null) {
metaRoute.meta = `{ ...(${metaImportName}) || {}), ...${route.meta} }`
}
if (route.alias != null) {
metaRoute.alias = `${route.alias}.concat(${metaImportName}?.alias || [])`
}
if (route.redirect != null) {
metaRoute.redirect = route.redirect
}
}
return metaRoute
})) }))
} }
} }

View File

@ -0,0 +1,441 @@
{
"route without file": [
{
"alias": "["sweet-home"]",
"meta": "{"hello":"world"}",
"name": ""home"",
"path": ""/"",
"redirect": undefined,
},
],
"should allow pages with `:` in their path": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/test:name.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "test:name"",
"path": "mockMeta?.path ?? "/test\\:name"",
"redirect": "mockMeta?.redirect",
},
],
"should correctly merge nested routes": [
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/param/index/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "param-index"",
"path": "mockMeta?.path ?? """,
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("layer/pages/param/index/sibling.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "param-index-sibling"",
"path": "mockMeta?.path ?? "sibling"",
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("layer/pages/param/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? undefined",
"path": "mockMeta?.path ?? """,
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/param/sibling.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "param-sibling"",
"path": "mockMeta?.path ?? "sibling"",
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("pages/param.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? undefined",
"path": "mockMeta?.path ?? "/param"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("layer/pages/wrapper-expose/other/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "wrapper-expose-other"",
"path": "mockMeta?.path ?? """,
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/wrapper-expose/other/sibling.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "wrapper-expose-other-sibling"",
"path": "mockMeta?.path ?? "sibling"",
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("pages/wrapper-expose/other.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? undefined",
"path": "mockMeta?.path ?? "/wrapper-expose/other"",
"redirect": "mockMeta?.redirect",
},
],
"should extract serializable values and override fallback when normalized with `overrideMeta: true`": [
{
"alias": "["sweet-home"].concat(mockMeta?.alias || [])",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "home"",
"path": "mockMeta?.path ?? "/"",
"redirect": ""/"",
},
],
"should generate correct catch-all route": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[...slug].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "slug"",
"path": "mockMeta?.path ?? "/:slug(.*)*"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
],
"should generate correct dynamic routes": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[slug].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "slug"",
"path": "mockMeta?.path ?? "/:slug()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[[foo]]/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "foo"",
"path": "mockMeta?.path ?? """,
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("pages/[[foo]]").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? undefined",
"path": "mockMeta?.path ?? "/:foo?"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/optional/[[opt]].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "optional-opt"",
"path": "mockMeta?.path ?? "/optional/:opt?"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/optional/prefix-[[opt]].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "optional-prefix-opt"",
"path": "mockMeta?.path ?? "/optional/prefix-:opt?"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/optional/[[opt]]-postfix.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "optional-opt-postfix"",
"path": "mockMeta?.path ?? "/optional/:opt?-postfix"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "optional-prefix-opt-postfix"",
"path": "mockMeta?.path ?? "/optional/prefix-:opt?-postfix"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[bar]/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "bar"",
"path": "mockMeta?.path ?? "/:bar()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/nonopt/[slug].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "nonopt-slug"",
"path": "mockMeta?.path ?? "/nonopt/:slug()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/opt/[[slug]].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "opt-slug"",
"path": "mockMeta?.path ?? "/opt/:slug?"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[[sub]]/route-[slug].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "sub-route-slug"",
"path": "mockMeta?.path ?? "/:sub?/route-:slug()"",
"redirect": "mockMeta?.redirect",
},
],
"should generate correct id for catchall (order 1)": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[...stories].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "stories"",
"path": "mockMeta?.path ?? "/:stories(.*)*"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/stories/[id].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "stories-id"",
"path": "mockMeta?.path ?? "/stories/:id()"",
"redirect": "mockMeta?.redirect",
},
],
"should generate correct id for catchall (order 2)": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/stories/[id].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "stories-id"",
"path": "mockMeta?.path ?? "/stories/:id()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[...stories].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "stories"",
"path": "mockMeta?.path ?? "/:stories(.*)*"",
"redirect": "mockMeta?.redirect",
},
],
"should generate correct route for kebab-case file": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/kebab-case.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "kebab-case"",
"path": "mockMeta?.path ?? "/kebab-case"",
"redirect": "mockMeta?.redirect",
},
],
"should generate correct route for snake_case file": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/snake_case.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "snake_case"",
"path": "mockMeta?.path ?? "/snake_case"",
"redirect": "mockMeta?.redirect",
},
],
"should generate correct routes for index pages": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/parent/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "parent"",
"path": "mockMeta?.path ?? "/parent"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/parent/child/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "parent-child"",
"path": "mockMeta?.path ?? "/parent/child"",
"redirect": "mockMeta?.redirect",
},
],
"should generate correct routes for parent/child": [
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/parent/child.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "parent-child"",
"path": "mockMeta?.path ?? "child"",
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("pages/parent.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "parent"",
"path": "mockMeta?.path ?? "/parent"",
"redirect": "mockMeta?.redirect",
},
],
"should handle trailing slashes with index routes": [
{
"alias": "mockMeta?.alias || []",
"children": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/index/index/all.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index-index-all"",
"path": "mockMeta?.path ?? "all"",
"redirect": "mockMeta?.redirect",
},
],
"component": "() => import("pages/index/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
],
"should not generate colliding route names when hyphens are in file name": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/parent/[child].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "parent-child"",
"path": "mockMeta?.path ?? "/parent/:child()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/parent-[child].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "parent-child"",
"path": "mockMeta?.path ?? "/parent-:child()"",
"redirect": "mockMeta?.redirect",
},
],
"should not merge required param as a child of optional param": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[[foo]].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "foo"",
"path": "mockMeta?.path ?? "/:foo?"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[foo].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "foo"",
"path": "mockMeta?.path ?? "/:foo()"",
"redirect": "mockMeta?.redirect",
},
],
"should only allow "_" & "." as special character for dynamic route": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[a1_1a].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "a1_1a"",
"path": "mockMeta?.path ?? "/:a1_1a()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[b2.2b].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "b2.2b"",
"path": "mockMeta?.path ?? "/:b2.2b()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[b2]_[2b].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "b2_2b"",
"path": "mockMeta?.path ?? "/:b2()_:2b()"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[[c3@3c]].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "c33c"",
"path": "mockMeta?.path ?? "/:c33c?"",
"redirect": "mockMeta?.redirect",
},
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/[[d4-4d]].vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "d44d"",
"path": "mockMeta?.path ?? "/:d44d?"",
"redirect": "mockMeta?.redirect",
},
],
"should properly override route name if definePageMeta name override is defined.": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "home"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
],
"should use fallbacks when normalized with `overrideMeta: true`": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name ?? "index"",
"path": "mockMeta?.path ?? "/"",
"redirect": "mockMeta?.redirect",
},
],
}

View File

@ -0,0 +1,303 @@
{
"route without file": [
{
"alias": "["sweet-home"]",
"meta": "{"hello":"world"}",
"name": ""home"",
"path": ""/"",
"redirect": undefined,
},
],
"should allow pages with `:` in their path": [
{
"component": "() => import("pages/test:name.vue").then(m => m.default || m)",
"name": ""test:name"",
"path": ""/test\\:name"",
},
],
"should correctly merge nested routes": [
{
"children": [
{
"children": [
{
"component": "() => import("pages/param/index/index.vue").then(m => m.default || m)",
"name": ""param-index"",
"path": """",
},
{
"component": "() => import("layer/pages/param/index/sibling.vue").then(m => m.default || m)",
"name": ""param-index-sibling"",
"path": ""sibling"",
},
],
"component": "() => import("layer/pages/param/index.vue").then(m => m.default || m)",
"name": "mockMeta?.name",
"path": """",
},
{
"component": "() => import("pages/param/sibling.vue").then(m => m.default || m)",
"name": ""param-sibling"",
"path": ""sibling"",
},
],
"component": "() => import("pages/param.vue").then(m => m.default || m)",
"name": "mockMeta?.name",
"path": ""/param"",
},
{
"children": [
{
"component": "() => import("layer/pages/wrapper-expose/other/index.vue").then(m => m.default || m)",
"name": ""wrapper-expose-other"",
"path": """",
},
{
"component": "() => import("pages/wrapper-expose/other/sibling.vue").then(m => m.default || m)",
"name": ""wrapper-expose-other-sibling"",
"path": ""sibling"",
},
],
"component": "() => import("pages/wrapper-expose/other.vue").then(m => m.default || m)",
"name": "mockMeta?.name",
"path": ""/wrapper-expose/other"",
},
],
"should extract serializable values and override fallback when normalized with `overrideMeta: true`": [
{
"alias": "["sweet-home"]",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": ""home"",
"path": ""/"",
"redirect": ""/"",
},
],
"should generate correct catch-all route": [
{
"component": "() => import("pages/[...slug].vue").then(m => m.default || m)",
"name": ""slug"",
"path": ""/:slug(.*)*"",
},
{
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"name": ""index"",
"path": ""/"",
},
],
"should generate correct dynamic routes": [
{
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"name": ""index"",
"path": ""/"",
},
{
"component": "() => import("pages/[slug].vue").then(m => m.default || m)",
"name": ""slug"",
"path": ""/:slug()"",
},
{
"children": [
{
"component": "() => import("pages/[[foo]]/index.vue").then(m => m.default || m)",
"name": ""foo"",
"path": """",
},
],
"component": "() => import("pages/[[foo]]").then(m => m.default || m)",
"name": "mockMeta?.name",
"path": ""/:foo?"",
},
{
"component": "() => import("pages/optional/[[opt]].vue").then(m => m.default || m)",
"name": ""optional-opt"",
"path": ""/optional/:opt?"",
},
{
"component": "() => import("pages/optional/prefix-[[opt]].vue").then(m => m.default || m)",
"name": ""optional-prefix-opt"",
"path": ""/optional/prefix-:opt?"",
},
{
"component": "() => import("pages/optional/[[opt]]-postfix.vue").then(m => m.default || m)",
"name": ""optional-opt-postfix"",
"path": ""/optional/:opt?-postfix"",
},
{
"component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue").then(m => m.default || m)",
"name": ""optional-prefix-opt-postfix"",
"path": ""/optional/prefix-:opt?-postfix"",
},
{
"component": "() => import("pages/[bar]/index.vue").then(m => m.default || m)",
"name": ""bar"",
"path": ""/:bar()"",
},
{
"component": "() => import("pages/nonopt/[slug].vue").then(m => m.default || m)",
"name": ""nonopt-slug"",
"path": ""/nonopt/:slug()"",
},
{
"component": "() => import("pages/opt/[[slug]].vue").then(m => m.default || m)",
"name": ""opt-slug"",
"path": ""/opt/:slug?"",
},
{
"component": "() => import("pages/[[sub]]/route-[slug].vue").then(m => m.default || m)",
"name": ""sub-route-slug"",
"path": ""/:sub?/route-:slug()"",
},
],
"should generate correct id for catchall (order 1)": [
{
"component": "() => import("pages/[...stories].vue").then(m => m.default || m)",
"name": ""stories"",
"path": ""/:stories(.*)*"",
},
{
"component": "() => import("pages/stories/[id].vue").then(m => m.default || m)",
"name": ""stories-id"",
"path": ""/stories/:id()"",
},
],
"should generate correct id for catchall (order 2)": [
{
"component": "() => import("pages/stories/[id].vue").then(m => m.default || m)",
"name": ""stories-id"",
"path": ""/stories/:id()"",
},
{
"component": "() => import("pages/[...stories].vue").then(m => m.default || m)",
"name": ""stories"",
"path": ""/:stories(.*)*"",
},
],
"should generate correct route for kebab-case file": [
{
"component": "() => import("pages/kebab-case.vue").then(m => m.default || m)",
"name": ""kebab-case"",
"path": ""/kebab-case"",
},
],
"should generate correct route for snake_case file": [
{
"component": "() => import("pages/snake_case.vue").then(m => m.default || m)",
"name": ""snake_case"",
"path": ""/snake_case"",
},
],
"should generate correct routes for index pages": [
{
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"name": ""index"",
"path": ""/"",
},
{
"component": "() => import("pages/parent/index.vue").then(m => m.default || m)",
"name": ""parent"",
"path": ""/parent"",
},
{
"component": "() => import("pages/parent/child/index.vue").then(m => m.default || m)",
"name": ""parent-child"",
"path": ""/parent/child"",
},
],
"should generate correct routes for parent/child": [
{
"children": [
{
"component": "() => import("pages/parent/child.vue").then(m => m.default || m)",
"name": ""parent-child"",
"path": ""child"",
},
],
"component": "() => import("pages/parent.vue").then(m => m.default || m)",
"name": ""parent"",
"path": ""/parent"",
},
],
"should handle trailing slashes with index routes": [
{
"children": [
{
"component": "() => import("pages/index/index/all.vue").then(m => m.default || m)",
"name": ""index-index-all"",
"path": ""all"",
},
],
"component": "() => import("pages/index/index.vue").then(m => m.default || m)",
"name": ""index"",
"path": ""/"",
},
],
"should not generate colliding route names when hyphens are in file name": [
{
"component": "() => import("pages/parent/[child].vue").then(m => m.default || m)",
"name": ""parent-child"",
"path": ""/parent/:child()"",
},
{
"component": "() => import("pages/parent-[child].vue").then(m => m.default || m)",
"name": ""parent-child"",
"path": ""/parent-:child()"",
},
],
"should not merge required param as a child of optional param": [
{
"component": "() => import("pages/[[foo]].vue").then(m => m.default || m)",
"name": ""foo"",
"path": ""/:foo?"",
},
{
"component": "() => import("pages/[foo].vue").then(m => m.default || m)",
"name": ""foo"",
"path": ""/:foo()"",
},
],
"should only allow "_" & "." as special character for dynamic route": [
{
"component": "() => import("pages/[a1_1a].vue").then(m => m.default || m)",
"name": ""a1_1a"",
"path": ""/:a1_1a()"",
},
{
"component": "() => import("pages/[b2.2b].vue").then(m => m.default || m)",
"name": ""b2.2b"",
"path": ""/:b2.2b()"",
},
{
"component": "() => import("pages/[b2]_[2b].vue").then(m => m.default || m)",
"name": ""b2_2b"",
"path": ""/:b2()_:2b()"",
},
{
"component": "() => import("pages/[[c3@3c]].vue").then(m => m.default || m)",
"name": ""c33c"",
"path": ""/:c33c?"",
},
{
"component": "() => import("pages/[[d4-4d]].vue").then(m => m.default || m)",
"name": ""d44d"",
"path": ""/:d44d?"",
},
],
"should properly override route name if definePageMeta name override is defined.": [
{
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"name": ""home"",
"path": ""/"",
},
],
"should use fallbacks when normalized with `overrideMeta: true`": [
{
"alias": "mockMeta?.alias || []",
"component": "() => import("pages/index.vue").then(m => m.default || m)",
"meta": "mockMeta || {}",
"name": "mockMeta?.name",
"path": ""/"",
"redirect": "mockMeta?.redirect",
},
],
}

View File

@ -1,15 +1,28 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { NuxtPage } from 'nuxt/schema' import type { NuxtPage } from 'nuxt/schema'
import { generateRoutesFromFiles, pathToNitroGlob } from '../src/pages/utils' import { generateRoutesFromFiles, normalizeRoutes, pathToNitroGlob } from '../src/pages/utils'
import { generateRouteKey } from '../src/pages/runtime/utils' import { generateRouteKey } from '../src/pages/runtime/utils'
describe('pages:generateRoutesFromFiles', () => { describe('pages:generateRoutesFromFiles', () => {
const pagesDir = 'pages' const pagesDir = 'pages'
const layerDir = 'layer/pages' const layerDir = 'layer/pages'
const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const
vi.mock('knitwork', async (original) => {
return {
...(await original<typeof import('knitwork')>()),
'genArrayFromRaw': (val: any) => val,
'genSafeVariableName': (..._args: string[]) => {
return 'mock'
},
}
})
const tests: Array<{ const tests: Array<{
description: string description: string
files: Array<{ path: string; template?: string; }> files?: Array<{ path: string; template?: string; }>
output?: NuxtPage[] output?: NuxtPage[]
normalized?: Record<string, any>[]
error?: string error?: string
}> = [ }> = [
{ {
@ -458,30 +471,118 @@ describe('pages:generateRoutesFromFiles', () => {
name: 'index', name: 'index',
path: '/' path: '/'
} }
],
},
{
description: 'should use fallbacks when normalized with `overrideMeta: true`',
files: [
{
path: `${pagesDir}/index.vue`,
template: `
<script setup lang="ts">
const routeName = ref('home')
const routeAliases = ref(['sweet-home'])
definePageMeta({
name: routeName.value,
alias: routeAliases.value,
hello: 'world',
redirect: () => '/'
})
</script>
`
}
],
output: [
{
name: 'index',
path: '/',
file: `${pagesDir}/index.vue`,
meta: { [DYNAMIC_META_KEY]: new Set(['name', 'alias', 'redirect', 'meta']) },
children: []
}
] ]
} },
{
description: 'should extract serializable values and override fallback when normalized with `overrideMeta: true`',
files: [
{
path: `${pagesDir}/index.vue`,
template: `
<script setup lang="ts">
definePageMeta({
name: 'home',
alias: ['sweet-home'],
redirect: '/',
hello: 'world'
})
</script>
`
}
],
output: [
{
name: 'home',
path: '/',
file: `${pagesDir}/index.vue`,
alias: ['sweet-home'],
redirect: '/',
children: [],
meta: { [DYNAMIC_META_KEY]: new Set(['meta']) },
}
]
},
{
description: 'route without file',
output: [
{
name: 'home',
path: '/',
alias: ['sweet-home'],
meta: { hello: 'world' },
}
]
},
] ]
const normalizedResults: Record<string, any> = {}
const normalizedOverrideMetaResults: Record<string, any> = {}
for (const test of tests) { for (const test of tests) {
it(test.description, async () => { it(test.description, async () => {
const vfs = Object.fromEntries(
test.files.map(file => [file.path, 'template' in file ? file.template : ''])
) as Record<string, string>
let result let result
try { if (test.files) {
result = await generateRoutesFromFiles(test.files.map(file => ({ const vfs = Object.fromEntries(
absolutePath: file.path, test.files.map(file => [file.path, 'template' in file ? file.template : ''])
relativePath: file.path.replace(/^(pages|layer\/pages)\//, '') ) as Record<string, string>
})), true, vfs)
} catch (error: any) { try {
expect(error.message).toEqual(test.error) result = await generateRoutesFromFiles(test.files.map(file => ({
absolutePath: file.path,
relativePath: file.path.replace(/^(pages|layer\/pages)\//, '')
})), { shouldExtractBuildMeta: true, vfs })
} catch (error: any) {
expect(error.message).toEqual(test.error)
}
} else {
result = test.output ?? []
} }
if (result) { if (result) {
expect(result).toEqual(test.output) expect(result).toEqual(test.output)
normalizedResults[test.description] = normalizeRoutes(result, new Set()).routes
normalizedOverrideMetaResults[test.description] = normalizeRoutes(result, new Set(), true).routes
} }
}) })
} }
it('should consistently normalize routes', async () => {
await expect(normalizedResults).toMatchFileSnapshot('./__snapshots__/pages-override-meta-disabled.test.ts.snap')
})
it('should consistently normalize routes when overriding meta', async () => {
await expect(normalizedOverrideMetaResults).toMatchFileSnapshot('./__snapshots__/pages-override-meta-enabled.test.ts.snap')
})
}) })
describe('pages:generateRouteKey', () => { describe('pages:generateRouteKey', () => {

View File

@ -261,6 +261,15 @@ export default defineUntypedSchema({
*/ */
inlineRouteRules: false, inlineRouteRules: false,
/**
* Allow exposing some route metadata defined in `definePageMeta` at build-time to modules (alias, name, path, redirect).
*
* This only works with static or strings/arrays rather than variables or conditional assignment.
*
* https://github.com/nuxt/nuxt/issues/24770
*/
scanPageMeta: false,
/** /**
* Automatically share payload _data_ between pages that are prerendered. This can result in a significant * Automatically share payload _data_ between pages that are prerendered. This can result in a significant
* performance improvement when prerendering sites that use `useAsyncData` or `useFetch` and fetch the same * performance improvement when prerendering sites that use `useAsyncData` or `useFetch` and fetch the same