fix(nuxt): preserve route metadata assigned outside page (#27587)

This commit is contained in:
Daniel Roe 2024-06-13 17:59:24 +01:00 committed by GitHub
parent 95458af9a1
commit 220cc502a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 274 additions and 59 deletions

View File

@ -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 || [])`
} }

View File

@ -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",
}, },

View 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)
}
]",
}
`)
})
})

View File

@ -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(/&quot;/g, '"').replace(/&gt;/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)

View File

@ -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
View 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>