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 { transform } from 'esbuild'
|
||||
import { parse } from 'acorn'
|
||||
import { walk } from 'estree-walker'
|
||||
import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree'
|
||||
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 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
|
||||
if (!(absolutePath in pageContentsCache) || pageContentsCache[absolutePath] !== contents) {
|
||||
pageContentsCache[absolutePath] = contents
|
||||
@ -199,17 +200,22 @@ async function getRouteMeta (contents: string, absolutePath: string): Promise<Pa
|
||||
ecmaVersion: 'latest',
|
||||
ranges: true,
|
||||
}) 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 extractionKeys = ['name', 'path', 'alias', 'redirect'] as const
|
||||
const dynamicProperties = new Set<keyof NuxtPage>()
|
||||
|
||||
let foundMeta = false
|
||||
|
||||
walk(ast, {
|
||||
enter (node) {
|
||||
if (foundMeta) { return }
|
||||
|
||||
if (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression' || node.expression.callee.type !== 'Identifier' || node.expression.callee.name !== 'definePageMeta') { return }
|
||||
|
||||
foundMeta = true
|
||||
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 }
|
||||
@ -263,6 +269,8 @@ async function getRouteMeta (contents: string, absolutePath: string): Promise<Pa
|
||||
extractedMeta.meta ??= {}
|
||||
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
metaCache[absolutePath] = extractedMeta
|
||||
return extractedMeta
|
||||
@ -505,19 +513,20 @@ async function createClientPage(loader) {
|
||||
}`)
|
||||
}
|
||||
|
||||
if (route.children != null) {
|
||||
if (route.children) {
|
||||
metaRoute.children = route.children
|
||||
}
|
||||
|
||||
if (overrideMeta) {
|
||||
metaRoute.name = `${metaImportName}?.name`
|
||||
metaRoute.path = `${metaImportName}?.path ?? ''`
|
||||
if (route.meta) {
|
||||
metaRoute.meta = `{ ...(${metaImportName} || {}), ...${route.meta} }`
|
||||
}
|
||||
|
||||
if (overrideMeta) {
|
||||
// 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]
|
||||
metaRoute[key] = route[key] ?? `${metaImportName}?.${key}`
|
||||
}
|
||||
|
||||
// set to extracted value or delete if none extracted
|
||||
@ -532,10 +541,6 @@ async function createClientPage(loader) {
|
||||
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 || [])`
|
||||
}
|
||||
|
@ -303,7 +303,7 @@
|
||||
"alias": "mockMeta?.alias || []",
|
||||
"component": "() => import("pages/index.vue").then(m => m.default || m)",
|
||||
"meta": "mockMeta || {}",
|
||||
"name": "mockMeta?.name",
|
||||
"name": "mockMeta?.name ?? "index"",
|
||||
"path": ""/"",
|
||||
"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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const { status, headers } = await fetch('/forbidden')
|
||||
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
|
||||
})
|
||||
},
|
||||
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
|
||||
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