mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 23:22:02 +00:00
feat(nuxt3): add support for definePageMeta
macro (#2678)
This commit is contained in:
parent
0e984fe496
commit
93ef422b5d
@ -34,15 +34,20 @@ Given the example above, you can use a custom layout like this:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
// This will also work in `<script setup>`
|
||||
definePageMeta({
|
||||
layout: "custom",
|
||||
};
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
::alert{type=info}
|
||||
Learn more about [defining page meta](/docs/directory-structure/pages#page-metadata).
|
||||
::
|
||||
|
||||
## Example: using with slots
|
||||
|
||||
You can also take full control (for example, with slots) by using the `<NuxtLayout>` component (which is globally available throughout your application) and set `layout: false` in your component options.
|
||||
You can also take full control (for example, with slots) by using the `<NuxtLayout>` component (which is globally available throughout your application) by setting `layout: false`.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
@ -53,51 +58,33 @@ You can also take full control (for example, with slots) by using the `<NuxtLayo
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Example: using with `<script setup>`
|
||||
## Example: changing the layout
|
||||
|
||||
If you are utilizing Vue `<script setup>` [compile-time syntactic sugar](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup), you can use a secondary `<script>` tag to set `layout` options as needed.
|
||||
|
||||
::alert{type=info}
|
||||
Learn more about [`<script setup>` and `<script>` tags co-existing](https://v3.vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script) in the Vue docs.
|
||||
::
|
||||
|
||||
Assuming this directory structure:
|
||||
|
||||
```bash
|
||||
-| layouts/
|
||||
---| custom.vue
|
||||
-| pages/
|
||||
---| my-page.vue
|
||||
```
|
||||
|
||||
And this `custom.vue` layout:
|
||||
You can also use a ref or computed property for your layout.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
Some shared layout content:
|
||||
<slot />
|
||||
<button @click="enableCustomLayout">Update layout</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
You can set a page layout in `my-page.vue` — alongside the `<script setup>` tag — like this:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
layout: "custom",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
// your setup script
|
||||
const route = useRoute()
|
||||
function enableCustomLayout () {
|
||||
// Note: because it's within a ref, it will persist if
|
||||
// you navigate away and then back to the page.
|
||||
route.layout.value = "custom"
|
||||
}
|
||||
definePageMeta({
|
||||
layout: ref(false),
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
@ -133,3 +133,52 @@ To display the `child.vue` component, you have to insert the `<NuxtNestedPage
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Page Metadata
|
||||
|
||||
You might want to define metadata for each route in your app. You can do this using the `definePageMeta` macro, which will work both in `<script>` and in `<script setup>`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: 'My home page'
|
||||
})
|
||||
```
|
||||
|
||||
This data can then be accessed throughout the rest of your app from the `route.meta` object.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
console.log(route.meta.title) // My home page
|
||||
</script>
|
||||
```
|
||||
|
||||
If you are using nested routes, the page metadata from all these routes will be merged into a single object. For more on route meta, see the [vue-router docs](https://next.router.vuejs.org/guide/advanced/meta.html#route-meta-fields).
|
||||
|
||||
Much like `defineEmits` or `defineProps` (see [Vue docs](https://v3.vuejs.org/api/sfc-script-setup.html#defineprops-and-defineemits)), `definePageMeta` is a **compiler macro**. It will be compiled away so you cannot reference it within your component. Instead, the metadata passed to it will be hoisted out of the component. Therefore, the page meta object cannot reference the component (or values defined on the component). However, it can reference imported bindings.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { someData } from '~/utils/example'
|
||||
|
||||
const title = ref('')
|
||||
|
||||
definePageMeta({
|
||||
title,
|
||||
someData
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Special Metadata
|
||||
|
||||
Of course, you are welcome to define metadata for your own use throughout your app. But some metadata defined with `definePageMeta` has a particular purpose:
|
||||
|
||||
#### `layout`
|
||||
|
||||
You can define the layout used to render the route. This can be either false (to disable any layout), a string or a ref/computed, if you want to make it reactive in some way. [More about layouts](/docs/directory-structure/layouts).
|
||||
|
||||
#### `transition`
|
||||
|
||||
You can define transition properties for the `<transition>` component that wraps your pages, or pass `false` to disable the `<transition>` wrapper for that route. [More about transitions](https://v3.vuejs.org/guide/transitions-overview.html).
|
||||
|
@ -11,8 +11,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default defineNuxtComponent({
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'custom'
|
||||
})
|
||||
</script>
|
||||
|
@ -18,8 +18,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
export default {
|
||||
layout: false,
|
||||
data: () => ({
|
||||
layout: 'custom'
|
||||
})
|
||||
|
@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
definePageMeta({
|
||||
layout: 'custom'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@ -37,7 +37,7 @@ export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => {
|
||||
enforce: 'post',
|
||||
transformInclude (id) {
|
||||
const { pathname, search } = parseURL(id)
|
||||
const { type } = parseQuery(search)
|
||||
const { type, macro } = parseQuery(search)
|
||||
|
||||
// Exclude node_modules by default
|
||||
if (ctx.transform.exclude.some(pattern => id.match(pattern))) {
|
||||
@ -47,7 +47,7 @@ export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => {
|
||||
// vue files
|
||||
if (
|
||||
pathname.endsWith('.vue') &&
|
||||
(type === 'template' || type === 'script' || !search)
|
||||
(type === 'template' || type === 'script' || macro || !search)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
92
packages/nuxt3/src/pages/macros.ts
Normal file
92
packages/nuxt3/src/pages/macros.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { createUnplugin } from 'unplugin'
|
||||
import { parseQuery, parseURL, withQuery } from 'ufo'
|
||||
import { findStaticImports, findExports } from 'mlly'
|
||||
|
||||
export interface TransformMacroPluginOptions {
|
||||
macros: Record<string, string>
|
||||
}
|
||||
|
||||
export const TransformMacroPlugin = createUnplugin((options: TransformMacroPluginOptions) => {
|
||||
return {
|
||||
name: 'nuxt-pages-macros-transform',
|
||||
enforce: 'post',
|
||||
transformInclude (id) {
|
||||
// We only process SFC files for macros
|
||||
return parseURL(id).pathname.endsWith('.vue')
|
||||
},
|
||||
transform (code, id) {
|
||||
const { search } = parseURL(id)
|
||||
|
||||
// Tree-shake out any runtime references to the macro.
|
||||
// We do this first as it applies to all files, not just those with the query
|
||||
for (const macro in options.macros) {
|
||||
const match = code.match(new RegExp(`\\b${macro}\\s*\\(\\s*`))?.[0]
|
||||
if (match) {
|
||||
code = code.replace(match, `/*#__PURE__*/ false && ${match}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!parseQuery(search).macro) { return code }
|
||||
|
||||
// [webpack] Re-export any imports from script blocks in the components
|
||||
// with workaround for vue-loader bug: https://github.com/vuejs/vue-loader/pull/1911
|
||||
const scriptImport = findStaticImports(code).find(i => parseQuery(i.specifier.replace('?macro=true', '')).type === 'script')
|
||||
if (scriptImport) {
|
||||
const specifier = withQuery(scriptImport.specifier.replace('?macro=true', ''), { macro: 'true' })
|
||||
return `export { meta } from "${specifier}"`
|
||||
}
|
||||
|
||||
const currentExports = findExports(code)
|
||||
for (const match of currentExports) {
|
||||
if (match.type !== 'default') { continue }
|
||||
if (match.specifier && match._type === 'named') {
|
||||
// [webpack] Export named exports rather than the default (component)
|
||||
return code.replace(match.code, `export {${Object.values(options.macros).join(', ')}} from "${match.specifier}"`)
|
||||
} else {
|
||||
// ensure we tree-shake any _other_ default exports out of the macro script
|
||||
code = code.replace(match.code, '/*#__PURE__*/ false &&')
|
||||
code += '\nexport default {}'
|
||||
}
|
||||
}
|
||||
|
||||
for (const macro in options.macros) {
|
||||
// Skip already-processed macros
|
||||
if (currentExports.some(e => e.name === options.macros[macro])) { continue }
|
||||
|
||||
const { 0: match, index = 0 } = code.match(new RegExp(`\\b${macro}\\s*\\(\\s*`)) || {} as RegExpMatchArray
|
||||
const macroContent = match ? extractObject(code.slice(index + match.length)) : 'undefined'
|
||||
|
||||
code += `\nexport const ${options.macros[macro]} = ${macroContent}`
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const starts = {
|
||||
'{': '}',
|
||||
'[': ']',
|
||||
'(': ')',
|
||||
'<': '>',
|
||||
'"': '"',
|
||||
"'": "'"
|
||||
}
|
||||
|
||||
function extractObject (code: string) {
|
||||
// Strip comments
|
||||
code = code.replace(/^\s*\/\/.*$/gm, '')
|
||||
|
||||
const stack = []
|
||||
let result = ''
|
||||
do {
|
||||
if (stack[0] === code[0] && result.slice(-1) !== '\\') {
|
||||
stack.shift()
|
||||
} else if (code[0] in starts) {
|
||||
stack.unshift(starts[code[0]])
|
||||
}
|
||||
result += code[0]
|
||||
code = code.slice(1)
|
||||
} while (stack.length && code.length)
|
||||
return result
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { defineNuxtModule, addTemplate, addPlugin, templateUtils } from '@nuxt/kit'
|
||||
import { defineNuxtModule, addTemplate, addPlugin, templateUtils, addVitePlugin, addWebpackPlugin } from '@nuxt/kit'
|
||||
import { resolve } from 'pathe'
|
||||
import { distDir } from '../dirs'
|
||||
import { resolveLayouts, resolvePagesRoutes, addComponentToRoutes } from './utils'
|
||||
import { resolveLayouts, resolvePagesRoutes, normalizeRoutes } from './utils'
|
||||
import { TransformMacroPlugin, TransformMacroPluginOptions } from './macros'
|
||||
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
@ -41,8 +42,18 @@ export default defineNuxtModule({
|
||||
const composablesFile = resolve(runtimeDir, 'composables')
|
||||
autoImports.push({ name: 'useRouter', as: 'useRouter', from: composablesFile })
|
||||
autoImports.push({ name: 'useRoute', as: 'useRoute', from: composablesFile })
|
||||
autoImports.push({ name: 'definePageMeta', as: 'definePageMeta', from: composablesFile })
|
||||
})
|
||||
|
||||
// Extract macros from pages
|
||||
const macroOptions: TransformMacroPluginOptions = {
|
||||
macros: {
|
||||
definePageMeta: 'meta'
|
||||
}
|
||||
}
|
||||
addVitePlugin(TransformMacroPlugin.vite(macroOptions))
|
||||
addWebpackPlugin(TransformMacroPlugin.webpack(macroOptions))
|
||||
|
||||
// Add router plugin
|
||||
addPlugin(resolve(runtimeDir, 'router'))
|
||||
|
||||
@ -52,8 +63,8 @@ export default defineNuxtModule({
|
||||
async getContents () {
|
||||
const pages = await resolvePagesRoutes(nuxt)
|
||||
await nuxt.callHook('pages:extend', pages)
|
||||
const serializedRoutes = addComponentToRoutes(pages)
|
||||
return `export default ${templateUtils.serialize(serializedRoutes)}`
|
||||
const { routes: serializedRoutes, imports } = normalizeRoutes(pages)
|
||||
return [...imports, `export default ${templateUtils.serialize(serializedRoutes)}`].join('\n')
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ComputedRef, /* KeepAliveProps, */ Ref, TransitionProps } from 'vue'
|
||||
import type { Router, RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useNuxtApp } from '#app'
|
||||
|
||||
@ -8,3 +9,29 @@ export const useRouter = () => {
|
||||
export const useRoute = () => {
|
||||
return useNuxtApp()._route as RouteLocationNormalizedLoaded
|
||||
}
|
||||
|
||||
export interface PageMeta {
|
||||
[key: string]: any
|
||||
transition?: false | TransitionProps
|
||||
layout?: false | string | Ref<false | string> | ComputedRef<false | string>
|
||||
// TODO: https://github.com/vuejs/vue-next/issues/3652
|
||||
// keepalive?: false | KeepAliveProps
|
||||
}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta extends PageMeta {}
|
||||
}
|
||||
|
||||
const warnRuntimeUsage = (method: string) =>
|
||||
console.warn(
|
||||
`${method}() is a compiler-hint helper that is only usable inside ` +
|
||||
'<script setup> of a single file component. Its arguments should be ' +
|
||||
'compiled away and passing it at runtime has no effect.'
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const definePageMeta = (meta: PageMeta): void => {
|
||||
if (process.dev) {
|
||||
warnRuntimeUsage('definePageMeta')
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { defineComponent, h, Ref } from 'vue'
|
||||
// @ts-ignore
|
||||
import layouts from '#build/layouts'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
name: {
|
||||
type: [String, Boolean],
|
||||
type: [String, Boolean, Object] as unknown as () => string | false | Ref<string | false>,
|
||||
default: 'default'
|
||||
}
|
||||
},
|
||||
setup (props, context) {
|
||||
return () => {
|
||||
const layout = props.name
|
||||
const layout = (props.name && typeof props.name === 'object' ? props.name.value : props.name) ?? 'default'
|
||||
if (!layouts[layout]) {
|
||||
if (process.dev && layout && layout !== 'default') {
|
||||
console.warn(`Invalid layout \`${layout}\` selected.`)
|
||||
|
@ -1,27 +1,34 @@
|
||||
<template>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<NuxtLayout v-if="Component" :name="layout || updatedComponentLayout || Component.type.layout">
|
||||
<transition name="page" mode="out-in">
|
||||
<!-- <keep-alive> -->
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<NuxtLayout v-if="Component" :name="layout || route.meta.layout">
|
||||
<NuxtTransition :options="route.meta.transition ?? { name: 'page', mode: 'out-in' }">
|
||||
<Suspense @pending="() => onSuspensePending(Component)" @resolve="() => onSuspenseResolved(Component)">
|
||||
<component :is="Component" :key="$route.path" />
|
||||
<component :is="Component" :key="route.path" />
|
||||
</Suspense>
|
||||
<!-- <keep-alive -->
|
||||
</transition>
|
||||
</NuxtTransition>
|
||||
</NuxtLayout>
|
||||
<!-- TODO: Handle 404 placeholder -->
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, Transition } from 'vue'
|
||||
import NuxtLayout from './layout'
|
||||
import { useNuxtApp } from '#app'
|
||||
|
||||
export default {
|
||||
const NuxtTransition = defineComponent({
|
||||
name: 'NuxtTransition',
|
||||
props: {
|
||||
options: [Object, Boolean]
|
||||
},
|
||||
setup (props, { slots }) {
|
||||
return () => props.options ? h(Transition, props.options, slots.default) : slots.default()
|
||||
}
|
||||
})
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NuxtPage',
|
||||
components: { NuxtLayout },
|
||||
components: { NuxtLayout, NuxtTransition },
|
||||
props: {
|
||||
layout: {
|
||||
type: String,
|
||||
@ -29,15 +36,9 @@ export default {
|
||||
}
|
||||
},
|
||||
setup () {
|
||||
// Disable HMR reactivity in production
|
||||
const updatedComponentLayout = process.dev ? ref(null) : null
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
function onSuspensePending (Component) {
|
||||
if (process.dev) {
|
||||
updatedComponentLayout.value = Component.type.layout || null
|
||||
}
|
||||
return nuxtApp.callHook('page:start', Component)
|
||||
}
|
||||
|
||||
@ -46,10 +47,9 @@ export default {
|
||||
}
|
||||
|
||||
return {
|
||||
updatedComponentLayout,
|
||||
onSuspensePending,
|
||||
onSuspenseResolved
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { basename, extname, relative, resolve } from 'pathe'
|
||||
import { encodePath } from 'ufo'
|
||||
import type { Nuxt, NuxtRoute } from '@nuxt/schema'
|
||||
import type { Nuxt, NuxtPage } from '@nuxt/schema'
|
||||
import { resolveFiles } from '@nuxt/kit'
|
||||
import { kebabCase } from 'scule'
|
||||
import { kebabCase, pascalCase } from 'scule'
|
||||
|
||||
enum SegmentParserState {
|
||||
initial,
|
||||
@ -32,15 +32,15 @@ export async function resolvePagesRoutes (nuxt: Nuxt) {
|
||||
return generateRoutesFromFiles(files, pagesDir)
|
||||
}
|
||||
|
||||
export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtRoute[] {
|
||||
const routes: NuxtRoute[] = []
|
||||
export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtPage[] {
|
||||
const routes: NuxtPage[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const segments = relative(pagesDir, file)
|
||||
.replace(new RegExp(`${extname(file)}$`), '')
|
||||
.split('/')
|
||||
|
||||
const route: NuxtRoute = {
|
||||
const route: NuxtPage = {
|
||||
name: '',
|
||||
path: '',
|
||||
file,
|
||||
@ -183,7 +183,7 @@ function parseSegment (segment: string) {
|
||||
return tokens
|
||||
}
|
||||
|
||||
function prepareRoutes (routes: NuxtRoute[], parent?: NuxtRoute) {
|
||||
function prepareRoutes (routes: NuxtPage[], parent?: NuxtPage) {
|
||||
for (const route of routes) {
|
||||
// Remove -index
|
||||
if (route.name) {
|
||||
@ -225,10 +225,18 @@ export async function resolveLayouts (nuxt: Nuxt) {
|
||||
})
|
||||
}
|
||||
|
||||
export function addComponentToRoutes (routes: NuxtRoute[]) {
|
||||
return routes.map(route => ({
|
||||
...route,
|
||||
children: route.children ? addComponentToRoutes(route.children) : [],
|
||||
component: `{() => import('${route.file}')}`
|
||||
}))
|
||||
export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> = new Set()): { imports: Set<string>, routes: NuxtPage[]} {
|
||||
return {
|
||||
imports: metaImports,
|
||||
routes: routes.map((route) => {
|
||||
const metaImportName = `${pascalCase(route.file.replace(/[^\w]/g, ''))}Meta`
|
||||
metaImports.add(`import { meta as ${metaImportName} } from '${route.file}?macro=true'`)
|
||||
return {
|
||||
...route,
|
||||
children: route.children ? normalizeRoutes(route.children, metaImports).routes : [],
|
||||
meta: route.meta || `{${metaImportName}}` as any,
|
||||
component: `{() => import('${route.file}')}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -33,9 +33,10 @@ type RenderResult = {
|
||||
export type TSReference = { types: string } | { path: string }
|
||||
|
||||
export type NuxtPage = {
|
||||
name?: string,
|
||||
path: string,
|
||||
file: string,
|
||||
name?: string
|
||||
path: string
|
||||
file: string
|
||||
meta?: Record<string, any>
|
||||
children?: NuxtPage[]
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user