mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
fix(nuxt): preserve route metadata assigned outside page (#27587)
This commit is contained in:
parent
95458af9a1
commit
220cc502a1
@ -9,6 +9,7 @@ import { filename } from 'pathe/utils'
|
|||||||
import { hash } from 'ohash'
|
import { hash } from 'ohash'
|
||||||
import { transform } from 'esbuild'
|
import { transform } from 'esbuild'
|
||||||
import { parse } from 'acorn'
|
import { parse } from 'acorn'
|
||||||
|
import { walk } from 'estree-walker'
|
||||||
import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree'
|
import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree'
|
||||||
import type { NuxtPage } from 'nuxt/schema'
|
import type { NuxtPage } from 'nuxt/schema'
|
||||||
|
|
||||||
@ -173,7 +174,7 @@ const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const
|
|||||||
|
|
||||||
const pageContentsCache: Record<string, string> = {}
|
const pageContentsCache: Record<string, string> = {}
|
||||||
const metaCache: Record<string, Partial<Record<keyof NuxtPage, any>>> = {}
|
const metaCache: Record<string, Partial<Record<keyof NuxtPage, any>>> = {}
|
||||||
async function getRouteMeta (contents: string, absolutePath: string): Promise<Partial<Record<keyof NuxtPage, any>>> {
|
export async function getRouteMeta (contents: string, absolutePath: string): Promise<Partial<Record<keyof NuxtPage, any>>> {
|
||||||
// set/update pageContentsCache, invalidate metaCache on cache mismatch
|
// set/update pageContentsCache, invalidate metaCache on cache mismatch
|
||||||
if (!(absolutePath in pageContentsCache) || pageContentsCache[absolutePath] !== contents) {
|
if (!(absolutePath in pageContentsCache) || pageContentsCache[absolutePath] !== contents) {
|
||||||
pageContentsCache[absolutePath] = contents
|
pageContentsCache[absolutePath] = contents
|
||||||
@ -199,70 +200,77 @@ async function getRouteMeta (contents: string, absolutePath: string): Promise<Pa
|
|||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
ranges: true,
|
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')
|
|
||||||
if (!pageMetaAST) {
|
|
||||||
metaCache[absolutePath] = {}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageMetaArgument = ((pageMetaAST as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression
|
|
||||||
const extractedMeta = {} as Partial<Record<keyof NuxtPage, any>>
|
const extractedMeta = {} as Partial<Record<keyof NuxtPage, any>>
|
||||||
const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const
|
const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const
|
||||||
const dynamicProperties = new Set<keyof NuxtPage>()
|
const dynamicProperties = new Set<keyof NuxtPage>()
|
||||||
|
|
||||||
for (const key of extractionKeys) {
|
let foundMeta = false
|
||||||
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') {
|
walk(ast, {
|
||||||
const valueString = js.code.slice(property.value.range![0], property.value.range![1])
|
enter (node) {
|
||||||
try {
|
if (foundMeta) { return }
|
||||||
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') {
|
if (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression' || node.expression.callee.type !== 'Identifier' || node.expression.callee.name !== 'definePageMeta') { return }
|
||||||
const values = []
|
|
||||||
for (const element of property.value.elements) {
|
foundMeta = true
|
||||||
if (!element) {
|
const pageMetaArgument = ((node as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression
|
||||||
|
|
||||||
|
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
|
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}\`).`)
|
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)
|
dynamicProperties.add(key)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
values.push(element.value)
|
extractedMeta[key] = property.value.value
|
||||||
}
|
}
|
||||||
extractedMeta[key] = values
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property.value.type !== 'Literal' || typeof property.value.value !== 'string') {
|
const extraneousMetaKeys = pageMetaArgument.properties
|
||||||
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not a string literal or array of string literals (reading \`${absolutePath}\`).`)
|
.filter(property => property.type === 'Property' && property.key.type === 'Identifier' && !(extractionKeys as unknown as string[]).includes(property.key.name))
|
||||||
dynamicProperties.add(key)
|
// @ts-expect-error inferred types have been filtered out
|
||||||
continue
|
.map(property => property.key.name)
|
||||||
}
|
|
||||||
extractedMeta[key] = property.value.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraneousMetaKeys = pageMetaArgument.properties
|
if (extraneousMetaKeys.length) {
|
||||||
.filter(property => property.type === 'Property' && property.key.type === 'Identifier' && !(extractionKeys as unknown as string[]).includes(property.key.name))
|
dynamicProperties.add('meta')
|
||||||
// @ts-expect-error inferred types have been filtered out
|
}
|
||||||
.map(property => property.key.name)
|
|
||||||
|
|
||||||
if (extraneousMetaKeys.length) {
|
if (dynamicProperties.size) {
|
||||||
dynamicProperties.add('meta')
|
extractedMeta.meta ??= {}
|
||||||
}
|
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
|
||||||
|
}
|
||||||
if (dynamicProperties.size) {
|
},
|
||||||
extractedMeta.meta ??= {}
|
})
|
||||||
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
|
|
||||||
}
|
|
||||||
|
|
||||||
metaCache[absolutePath] = extractedMeta
|
metaCache[absolutePath] = extractedMeta
|
||||||
return extractedMeta
|
return extractedMeta
|
||||||
@ -505,19 +513,20 @@ async function createClientPage(loader) {
|
|||||||
}`)
|
}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.children != null) {
|
if (route.children) {
|
||||||
metaRoute.children = route.children
|
metaRoute.children = route.children
|
||||||
}
|
}
|
||||||
|
|
||||||
if (overrideMeta) {
|
if (route.meta) {
|
||||||
metaRoute.name = `${metaImportName}?.name`
|
metaRoute.meta = `{ ...(${metaImportName} || {}), ...${route.meta} }`
|
||||||
metaRoute.path = `${metaImportName}?.path ?? ''`
|
}
|
||||||
|
|
||||||
|
if (overrideMeta) {
|
||||||
// skip and retain fallback if marked dynamic
|
// skip and retain fallback if marked dynamic
|
||||||
// set to extracted value or fallback if none extracted
|
// set to extracted value or fallback if none extracted
|
||||||
for (const key of ['name', 'path'] satisfies NormalizedRouteKeys) {
|
for (const key of ['name', 'path'] satisfies NormalizedRouteKeys) {
|
||||||
if (markedDynamic.has(key)) { continue }
|
if (markedDynamic.has(key)) { continue }
|
||||||
metaRoute[key] = route[key] ?? metaRoute[key]
|
metaRoute[key] = route[key] ?? `${metaImportName}?.${key}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// set to extracted value or delete if none extracted
|
// set to extracted value or delete if none extracted
|
||||||
@ -532,10 +541,6 @@ async function createClientPage(loader) {
|
|||||||
metaRoute[key] = route[key]
|
metaRoute[key] = route[key]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (route.meta != null) {
|
|
||||||
metaRoute.meta = `{ ...(${metaImportName} || {}), ...${route.meta} }`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route.alias != null) {
|
if (route.alias != null) {
|
||||||
metaRoute.alias = `${route.alias}.concat(${metaImportName}?.alias || [])`
|
metaRoute.alias = `${route.alias}.concat(${metaImportName}?.alias || [])`
|
||||||
}
|
}
|
||||||
|
@ -303,7 +303,7 @@
|
|||||||
"alias": "mockMeta?.alias || []",
|
"alias": "mockMeta?.alias || []",
|
||||||
"component": "() => import("pages/index.vue").then(m => m.default || m)",
|
"component": "() => import("pages/index.vue").then(m => m.default || m)",
|
||||||
"meta": "mockMeta || {}",
|
"meta": "mockMeta || {}",
|
||||||
"name": "mockMeta?.name",
|
"name": "mockMeta?.name ?? "index"",
|
||||||
"path": ""/"",
|
"path": ""/"",
|
||||||
"redirect": "mockMeta?.redirect",
|
"redirect": "mockMeta?.redirect",
|
||||||
},
|
},
|
||||||
|
148
packages/nuxt/test/page-metadata.test.ts
Normal file
148
packages/nuxt/test/page-metadata.test.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { getRouteMeta, normalizeRoutes } from '../src/pages/utils'
|
||||||
|
import type { NuxtPage } from '../schema'
|
||||||
|
|
||||||
|
const filePath = '/app/pages/index.vue'
|
||||||
|
|
||||||
|
describe('page metadata', () => {
|
||||||
|
it('should not extract metadata from empty files', async () => {
|
||||||
|
expect(await getRouteMeta('', filePath)).toEqual({})
|
||||||
|
expect(await getRouteMeta('<template><div>Hi</div></template>', filePath)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use and invalidate cache', async () => {
|
||||||
|
const fileContents = `<script setup>definePageMeta({ foo: 'bar' })</script>`
|
||||||
|
const meta = await getRouteMeta(fileContents, filePath)
|
||||||
|
expect(meta === await getRouteMeta(fileContents, filePath)).toBeTruthy()
|
||||||
|
expect(meta === await getRouteMeta(fileContents, '/app/pages/other.vue')).toBeFalsy()
|
||||||
|
expect(meta === await getRouteMeta('<template><div>Hi</div></template>' + fileContents, filePath)).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract serialisable metadata', async () => {
|
||||||
|
const meta = await getRouteMeta(`
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
name: 'some-custom-name',
|
||||||
|
path: '/some-custom-path',
|
||||||
|
validate: () => true,
|
||||||
|
middleware: [
|
||||||
|
function () {},
|
||||||
|
],
|
||||||
|
otherValue: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
`, filePath)
|
||||||
|
|
||||||
|
expect(meta).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"__nuxt_dynamic_meta_key": Set {
|
||||||
|
"meta",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"name": "some-custom-name",
|
||||||
|
"path": "/some-custom-path",
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract serialisable metadata in options api', async () => {
|
||||||
|
const meta = await getRouteMeta(`
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
setup() {
|
||||||
|
definePageMeta({
|
||||||
|
name: 'some-custom-name',
|
||||||
|
path: '/some-custom-path',
|
||||||
|
middleware: (from, to) => console.warn('middleware'),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
`, filePath)
|
||||||
|
|
||||||
|
expect(meta).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"__nuxt_dynamic_meta_key": Set {
|
||||||
|
"meta",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"name": "some-custom-name",
|
||||||
|
"path": "/some-custom-path",
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeRoutes', () => {
|
||||||
|
it('should produce valid route objects when used with extracted meta', async () => {
|
||||||
|
const page: NuxtPage = { path: '/', file: filePath }
|
||||||
|
Object.assign(page, await getRouteMeta(`
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
name: 'some-custom-name',
|
||||||
|
path: ref('/some-custom-path'), /* dynamic */
|
||||||
|
validate: () => true,
|
||||||
|
redirect: '/',
|
||||||
|
middleware: [
|
||||||
|
function () {},
|
||||||
|
],
|
||||||
|
otherValue: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
`, filePath))
|
||||||
|
|
||||||
|
page.meta ||= {}
|
||||||
|
page.meta.layout = 'test'
|
||||||
|
page.meta.foo = 'bar'
|
||||||
|
|
||||||
|
const { routes, imports } = normalizeRoutes([page], new Set(), true)
|
||||||
|
expect({ routes, imports }).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"imports": Set {
|
||||||
|
"import { default as indexN6pT4Un8hYMeta } from "/app/pages/index.vue?macro=true";",
|
||||||
|
},
|
||||||
|
"routes": "[
|
||||||
|
{
|
||||||
|
name: "some-custom-name",
|
||||||
|
path: indexN6pT4Un8hYMeta?.path ?? "/",
|
||||||
|
meta: { ...(indexN6pT4Un8hYMeta || {}), ...{"layout":"test","foo":"bar"} },
|
||||||
|
redirect: "/",
|
||||||
|
component: () => import("/app/pages/index.vue").then(m => m.default || m)
|
||||||
|
}
|
||||||
|
]",
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should produce valid route objects when used without extracted meta', () => {
|
||||||
|
const page: NuxtPage = { path: '/', file: filePath }
|
||||||
|
page.meta ||= {}
|
||||||
|
page.meta.layout = 'test'
|
||||||
|
page.meta.foo = 'bar'
|
||||||
|
|
||||||
|
const { routes, imports } = normalizeRoutes([page], new Set())
|
||||||
|
expect({ routes, imports }).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"imports": Set {
|
||||||
|
"import { default as indexN6pT4Un8hYMeta } from "/app/pages/index.vue?macro=true";",
|
||||||
|
},
|
||||||
|
"routes": "[
|
||||||
|
{
|
||||||
|
name: indexN6pT4Un8hYMeta?.name ?? undefined,
|
||||||
|
path: indexN6pT4Un8hYMeta?.path ?? "/",
|
||||||
|
meta: { ...(indexN6pT4Un8hYMeta || {}), ...{"layout":"test","foo":"bar"} },
|
||||||
|
alias: indexN6pT4Un8hYMeta?.alias || [],
|
||||||
|
redirect: indexN6pT4Un8hYMeta?.redirect,
|
||||||
|
component: () => import("/app/pages/index.vue").then(m => m.default || m)
|
||||||
|
}
|
||||||
|
]",
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
@ -166,6 +166,21 @@ describe('pages', () => {
|
|||||||
expect(res.headers.get('x-extend')).toEqual('added in pages:extend')
|
expect(res.headers.get('x-extend')).toEqual('added in pages:extend')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves page metadata added in pages:extend hook', async () => {
|
||||||
|
const html = await $fetch<string>('/some-custom-path')
|
||||||
|
expect (html.match(/<pre>([^<]*)<\/pre>/)?.[1]?.trim().replace(/"/g, '"').replace(/>/g, '>')).toMatchInlineSnapshot(`
|
||||||
|
"{
|
||||||
|
"name": "some-custom-name",
|
||||||
|
"path": "/some-custom-path",
|
||||||
|
"validate": "() => true",
|
||||||
|
"middleware": [
|
||||||
|
"() => true"
|
||||||
|
],
|
||||||
|
"otherValue": "{\\"foo\\":\\"bar\\"}"
|
||||||
|
}"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
it('validates routes', async () => {
|
it('validates routes', async () => {
|
||||||
const { status, headers } = await fetch('/forbidden')
|
const { status, headers } = await fetch('/forbidden')
|
||||||
expect(status).toEqual(404)
|
expect(status).toEqual(404)
|
||||||
|
11
test/fixtures/basic/nuxt.config.ts
vendored
11
test/fixtures/basic/nuxt.config.ts
vendored
@ -151,6 +151,17 @@ export default defineNuxtConfig({
|
|||||||
internalParent!.children = newPages
|
internalParent!.children = newPages
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
function (_options, nuxt) {
|
||||||
|
// to check that page metadata is preserved
|
||||||
|
nuxt.hook('pages:extend', (pages) => {
|
||||||
|
const customName = pages.find(page => page.name === 'some-custom-name')
|
||||||
|
if (!customName) { throw new Error('Page with custom name not found') }
|
||||||
|
if (customName.path !== '/some-custom-path') { throw new Error('Page path not extracted') }
|
||||||
|
|
||||||
|
customName.meta ||= {}
|
||||||
|
customName.meta.someProp = true
|
||||||
|
})
|
||||||
|
},
|
||||||
// To test falsy module values
|
// To test falsy module values
|
||||||
undefined,
|
undefined,
|
||||||
],
|
],
|
||||||
|
36
test/fixtures/basic/pages/meta.vue
vendored
Normal file
36
test/fixtures/basic/pages/meta.vue
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
name: 'some-custom-name',
|
||||||
|
path: '/some-custom-path',
|
||||||
|
validate: () => true,
|
||||||
|
middleware: [() => true],
|
||||||
|
otherValue: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const serialisedMeta: Record<string, string> = {}
|
||||||
|
const meta = useRoute().meta
|
||||||
|
for (const key in meta) {
|
||||||
|
if (Array.isArray(meta[key])) {
|
||||||
|
serialisedMeta[key] = meta[key].map((fn: Function) => fn.toString())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof meta[key] === 'string') {
|
||||||
|
serialisedMeta[key] = meta[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof meta[key] === 'object') {
|
||||||
|
serialisedMeta[key] = JSON.stringify(meta[key])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof meta[key] === 'function') {
|
||||||
|
serialisedMeta[key] = meta[key].toString()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<pre>{{ serialisedMeta }}</pre>
|
||||||
|
</template>
|
Loading…
Reference in New Issue
Block a user